Compare commits

...

54 Commits

Author SHA1 Message Date
streap2
4db8271ecf tarif dans footer 2026-05-10 07:19:57 +02:00
streap2
e6d6770c26 modification pour mac 2026-05-10 06:31:58 +02:00
371ed17867 Merge branch 'develop' 2026-05-09 21:35:43 +02:00
f6e3c1bb8c feat: add Twitter support to business profile and implement DashboardProfile component 2026-05-09 21:19:52 +02:00
6acd5ebe2e feat: implement business detail client view with interactive features including rating, favoriting, messaging, and analytics tracking 2026-05-09 21:12:01 +02:00
6a42c52b58 ok 2026-05-09 12:23:29 +02:00
72c24e0cc6 rectification branch develop 2026-05-09 12:23:26 +02:00
67df2d95e9 correction voir ma fiche 2026-05-09 12:13:36 +02:00
4aedcdb45a correction turbo pack 2026-05-09 11:02:48 +02:00
bac36efb94 feat: add Dockerfile for containerized deployment of the Next.js application
Some checks failed
Build and Push App / build (push) Failing after 4m27s
2026-05-06 22:37:47 +02:00
d1b32cd401 feat: add global error boundary component for handling critical application errors 2026-05-06 21:56:52 +02:00
2148f7281e docs: remove obsolete project configuration and documentation files 2026-05-06 21:14:58 +02:00
7b015cb758 hul correctuib 2026-05-06 21:00:15 +02:00
bb464cf1c5 feat: implement DynamicProviders to disable SSR for global context providers in root layout 2026-05-06 20:47:29 +02:00
19b405d9f2 feat: implement RootLayout with dynamic provider loading and metadata generation 2026-05-06 20:37:39 +02:00
a37b34f758 feat: initialize root layout with dynamic provider rendering and site metadata configuration 2026-05-06 20:28:21 +02:00
13c88dd4f7 ajout provider pour rendre le layout dynamique 2026-05-06 20:19:09 +02:00
52a9d47c30 correction global error détachement du layout 2026-05-06 19:53:36 +02:00
f93d70c447 suppression du run build admin pour le build normal 2026-05-06 17:29:16 +02:00
c73763be8a suppression build .next erreur global-error 2026-05-06 17:22:36 +02:00
268e14f369 modification du global error 2026-05-06 09:03:06 +02:00
e1aa0f076a restauration fichier docker 2026-05-06 08:19:46 +02:00
3d7cb5f4d1 maj plantage turbopack 2026-05-05 23:13:31 +02:00
2d9d707251 correction build error 2026-05-04 21:40:18 +02:00
5720661f8d maj banniere prod/dev 2026-05-04 21:30:10 +02:00
1b6d409319 maj color develop 2026-05-04 21:18:41 +02:00
75ae9c5f3d pré supp dockerfile 2026-05-04 20:49:21 +02:00
63b5af4868 suppression des fichiers inutile 2026-05-04 20:47:35 +02:00
a23c3bf780 maj bdd instruction 2026-05-04 20:34:13 +02:00
16d66c0456 migration correction et maj
Some checks failed
Build and Push App / build (push) Failing after 11m56s
2026-05-03 23:15:14 +02:00
5f421b418e Maj taxonomies
Some checks failed
Build and Push App / build (push) Failing after 11m28s
maj url photo
+ page 404
+ gestion programmation d'article
2026-05-03 21:22:12 +02:00
0e72867d4c correction deploy
All checks were successful
Build and Push App / build (push) Successful in 5m57s
2026-05-03 16:21:12 +02:00
2122075801 maj package
All checks were successful
Build and Push App / build (push) Successful in 6m49s
2026-05-03 13:22:40 +02:00
0f526c851a correction package json
All checks were successful
Build and Push App / build (push) Successful in 6m16s
2026-05-03 12:44:13 +02:00
79a9cbffe9 correction deploiement
Some checks failed
Build and Push App / build (push) Failing after 7s
2026-05-02 22:05:26 +02:00
f450f8ee03 correction faille de securité
All checks were successful
Build and Push App / build (push) Successful in 5m50s
2026-05-01 21:48:00 +02:00
9c003d1b7d feat: implement full authentication system with CSRF protection and email verification flow
All checks were successful
Build and Push App / build (push) Successful in 5m59s
2026-04-30 23:46:34 +02:00
4e881bcbe6 feat: implement business and user CRUD API endpoints with authentication utilities and update feature documentation
All checks were successful
Build and Push App / build (push) Successful in 5m46s
2026-04-30 23:05:04 +02:00
81b068e5c9 feat: initialize Prisma database with migration and seed scripts for blog and afrolife data
Some checks failed
Build and Push App / build (push) Failing after 11m14s
2026-04-28 16:03:37 +02:00
b55b15a2aa correction build
Some checks failed
Build and Push App / build (push) Failing after 22s
2026-04-27 23:32:11 +02:00
159f1c1709 maj npx prisma migrate au lieu de generate
Some checks failed
Build and Push App / build (push) Failing after 40s
2026-04-27 23:18:54 +02:00
a814c7b577 ajout de la gestion des favoris
All checks were successful
Build and Push App / build (push) Successful in 13m38s
2026-04-27 21:44:48 +02:00
38caf36bee correction redirection verification mail
All checks were successful
Build and Push App / build (push) Successful in 11m14s
2026-04-27 08:47:05 +02:00
streap2
ef7ac122f8 feat: add email verification resend functionality to login page and backend route
All checks were successful
Build and Push App / build (push) Successful in 5m35s
2026-04-27 08:01:51 +02:00
streap2
7900c9d5be fix: debug SMTP configuration and disable TLS certificate verification, and update Next.js route type path
All checks were successful
Build and Push App / build (push) Successful in 6m2s
2026-04-27 07:39:57 +02:00
b89dc62408 fix: add --accept-data-loss flag to prisma db push in start script
All checks were successful
Build and Push App / build (push) Successful in 7m45s
2026-04-26 23:33:23 +02:00
792a951e02 feat: initialize Prisma schema with core business, user, and communication models and configure docker-compose for database deployment
All checks were successful
Build and Push App / build (push) Successful in 8m16s
2026-04-26 23:10:05 +02:00
27b35104b9 feat: initialize Prisma schema with models for users, businesses, content, and site settings
Some checks failed
Build and Push App / build (push) Has been cancelled
2026-04-26 23:01:45 +02:00
d932186727 feat: initialize admin package with Next.js 16 and Prisma dependencies and update type declaration route path
Some checks failed
Build and Push App / build (push) Failing after 1m10s
2026-04-26 22:51:04 +02:00
281fb764c4 feat: implement authentication system with email verification, user management dashboard, and administrative business controls
Some checks failed
Build and Push App / build (push) Failing after 1m9s
2026-04-26 22:46:32 +02:00
streap2
31fa2fcde3 Maj smtp, reset password, et ajout afroshine
All checks were successful
Build and Push App / build (push) Successful in 10m29s
2026-04-26 14:32:38 +02:00
streap2
a551fe6bcc chore: force dynamic rendering for sitemap generation
Some checks failed
Build and Push App / build (push) Failing after 9m35s
2026-04-26 08:48:01 +02:00
streap2
668b6bd8b8 chore: downgrade Prisma to v6.19.3, remove redundant config files, and update schema datasource configurations
All checks were successful
Build and Push App / build (push) Successful in 17m49s
2026-04-26 08:21:36 +02:00
streap2
d1e551bcca feat: implement SEO enhancements, automatic slug generation, and font optimizations
All checks were successful
Build and Push App / build (push) Successful in 9m12s
2026-04-26 08:11:31 +02:00
123 changed files with 8299 additions and 5029 deletions

5
.env
View File

@@ -1,5 +1,8 @@
# Connexion locale via le Docker Compose fourni :
DATABASE_URL="postgresql://admin:adminpassword@localhost:5432/afrohub_dev?schema=public"
#DATABASE_URL="postgresql://admin:adminpassword@localhost:5432/afrohub_dev?schema=public"
DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?schema=public"
# Exemple avec Supabase ou Neon (si base distante)
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
# Environnement : "development" = accents rouges, "production" = accents normaux
NEXT_PUBLIC_APP_ENV=development

View File

@@ -1,28 +0,0 @@
name: Build and Push App
on:
push:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
# FORCE l'image ici pour être sûr d'avoir l'outil Docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Login to Gitea Registry
run: |
echo "${{ secrets.TOKEN }}" | docker login git.home.arrondeau.fr -u ${{ gitea.actor }} --password-stdin
- name: Build and Push Docker Images
run: |
# Build and Push FRONT
docker build -t git.home.arrondeau.fr/${{ gitea.repository }}:latest .
docker push git.home.arrondeau.fr/${{ gitea.repository }}:latest
# Build and Push ADMIN
docker build -t git.home.arrondeau.fr/${{ gitea.repository }}-admin:latest ./admin
docker push git.home.arrondeau.fr/${{ gitea.repository }}-admin:latest

View File

@@ -1,8 +1,12 @@
import type { NextConfig } from "next";
import path from "path";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
turbopack: {
root: path.join(__dirname, ".."),
},
};
export default nextConfig;

774
admin/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,17 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"@prisma/config": "^7.7.0",
"@prisma/adapter-pg": "6.19.3",
"@prisma/client": "6.19.3",
"@prisma/config": "6.19.3",
"@types/bcryptjs": "^2.4.6",
"afrohub": "file:..",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.1",
"lucide-react": "^1.8.0",
"next": "16.2.3",
"pg": "^8.20.0",
"prisma": "^7.7.0",
"prisma": "6.19.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hot-toast": "^2.6.0",

View File

@@ -1,14 +0,0 @@
import { config } from 'dotenv';
// Load .env then override with .env.local for Next.js compatibility
config();
config({ path: '.env.local', override: true });
/**
* Prisma 7 Configuration
*/
export default {
datasource: {
url: process.env.DATABASE_URL,
},
};

Binary file not shown.

View File

@@ -0,0 +1,459 @@
-- CreateEnum
CREATE TYPE "RatingStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('PENDING', 'RESOLVED', 'DISMISSED');
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('VISITOR', 'ENTREPRENEUR', 'ADMIN');
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('STARTER', 'BOOSTER', 'EMPIRE');
-- CreateEnum
CREATE TYPE "OfferType" AS ENUM ('PRODUCT', 'SERVICE');
-- CreateEnum
CREATE TYPE "InterviewType" AS ENUM ('VIDEO', 'ARTICLE');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'VISITOR',
"avatar" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"bio" TEXT,
"location" TEXT,
"phone" TEXT,
"isSuspended" BOOLEAN NOT NULL DEFAULT false,
"suspensionReason" TEXT,
"countryId" TEXT,
"resetToken" TEXT,
"resetTokenExpiry" TIMESTAMP(3),
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"verificationToken" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Business" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"location" TEXT NOT NULL,
"description" TEXT NOT NULL,
"logoUrl" TEXT NOT NULL,
"videoUrl" TEXT,
"contactEmail" TEXT NOT NULL,
"contactPhone" TEXT,
"socialLinks" JSONB,
"verified" BOOLEAN NOT NULL DEFAULT false,
"viewCount" INTEGER NOT NULL DEFAULT 0,
"rating" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"tags" TEXT[],
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"founderName" TEXT,
"founderImageUrl" TEXT,
"keyMetric" TEXT,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"slug" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT false,
"showEmail" BOOLEAN NOT NULL DEFAULT true,
"showPhone" BOOLEAN NOT NULL DEFAULT true,
"showSocials" BOOLEAN NOT NULL DEFAULT true,
"websiteUrl" TEXT,
"isSuspended" BOOLEAN NOT NULL DEFAULT false,
"suspensionReason" TEXT,
"plan" "Plan" NOT NULL DEFAULT 'STARTER',
"city" TEXT,
"countryId" TEXT,
"coverUrl" TEXT,
"coverPosition" TEXT DEFAULT '50% 50%',
"coverZoom" DOUBLE PRECISION DEFAULT 1.0,
"suggestedCategory" TEXT,
"categoryId" TEXT,
CONSTRAINT "Business_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BusinessCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"icon" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BusinessCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Country" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"flag" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"description" TEXT,
CONSTRAINT "Country_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PricingPlan" (
"id" TEXT NOT NULL,
"tier" "Plan" NOT NULL,
"name" TEXT NOT NULL,
"priceXOF" TEXT NOT NULL,
"priceEUR" TEXT NOT NULL,
"description" TEXT NOT NULL,
"features" TEXT[],
"offerLimit" INTEGER NOT NULL DEFAULT 1,
"recommended" BOOLEAN NOT NULL DEFAULT false,
"color" TEXT NOT NULL DEFAULT 'gray',
"updatedAt" TIMESTAMP(3) NOT NULL,
"yearlyPriceEUR" TEXT,
"yearlyPriceXOF" TEXT,
CONSTRAINT "PricingPlan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Offer" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"type" "OfferType" NOT NULL,
"price" DOUBLE PRECISION NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'XOF',
"description" TEXT,
"imageUrl" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"businessId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Offer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BlogPost" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"excerpt" TEXT NOT NULL,
"content" TEXT NOT NULL,
"author" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"imageUrl" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"metaDescription" TEXT,
"metaTitle" TEXT,
"slug" TEXT,
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Interview" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"guestName" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"role" TEXT NOT NULL,
"type" "InterviewType" NOT NULL,
"thumbnailUrl" TEXT NOT NULL,
"videoUrl" TEXT,
"content" TEXT,
"excerpt" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"duration" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"metaDescription" TEXT,
"metaTitle" TEXT,
"slug" TEXT,
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
CONSTRAINT "Interview_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnalyticsEvent" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"path" TEXT NOT NULL,
"label" TEXT,
"value" DOUBLE PRECISION,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"browser" TEXT,
"city" TEXT,
"country" TEXT,
"device" TEXT,
"ip" TEXT,
"os" TEXT,
"referrer" TEXT,
"userId" TEXT,
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rating" (
"id" TEXT NOT NULL,
"value" INTEGER NOT NULL,
"userId" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"comment" TEXT,
"reply" TEXT,
"replyAt" TIMESTAMP(3),
"status" "RatingStatus" NOT NULL DEFAULT 'PENDING',
CONSTRAINT "Rating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Conversation" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Conversation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ConversationParticipant" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "ConversationParticipant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Message" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"read" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MessageReport" (
"id" TEXT NOT NULL,
"reason" TEXT,
"messageId" TEXT NOT NULL,
"reporterId" TEXT NOT NULL,
"status" "ReportStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MessageReport_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SiteSetting" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"siteName" TEXT NOT NULL DEFAULT 'Afrohub',
"siteSlogan" TEXT NOT NULL DEFAULT 'La plateforme de référence pour l''entrepreneuriat africain.',
"contactEmail" TEXT NOT NULL DEFAULT 'support@afrohub.com',
"contactPhone" TEXT DEFAULT '+225 00 00 00 00 00',
"address" TEXT DEFAULT 'Abidjan, Côte d''Ivoire',
"facebookUrl" TEXT,
"twitterUrl" TEXT,
"instagramUrl" TEXT,
"linkedinUrl" TEXT,
"footerText" TEXT DEFAULT '© 2025 Afrohub. Tous droits réservés.',
"updatedAt" TIMESTAMP(3) NOT NULL,
"homeBlogCount" INTEGER NOT NULL DEFAULT 3,
"homeBlogShow" BOOLEAN NOT NULL DEFAULT true,
"homeBlogSubtitle" TEXT NOT NULL DEFAULT 'Toutes les actualités de l''entrepreneuriat africain.',
"homeBlogTitle" TEXT NOT NULL DEFAULT 'Derniers Articles',
"homeBlogCategories" TEXT[] DEFAULT ARRAY[]::TEXT[],
"homeBlogIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"homeBlogSelection" TEXT NOT NULL DEFAULT 'latest',
"homeCategories" TEXT[] DEFAULT ARRAY['Technologie & IT', 'Agriculture & Agrobusiness', 'Mode & Textile', 'Cosmétique & Beauté']::TEXT[],
"resetPasswordSubject" TEXT NOT NULL DEFAULT 'Réinitialisation de votre mot de passe - Afrohub',
"resetPasswordTemplate" TEXT NOT NULL DEFAULT '<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;"><h2 style="color: #0d9488; text-align: center;">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style="text-align: center; margin: 30px 0;"><a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n''avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;"><p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 {siteName}. Tous droits réservés.</p></div>',
"verifyEmailSubject" TEXT NOT NULL DEFAULT 'Vérification de votre compte - Afrohub',
"verifyEmailTemplate" TEXT NOT NULL DEFAULT '<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;"><h2 style="color: #0d9488; text-align: center;">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style="text-align: center; margin: 30px 0;"><a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a></div><p>Si vous n''avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;"><p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 {siteName}. Tous droits réservés.</p></div>',
CONSTRAINT "SiteSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LegalDocument" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LegalDocument_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Event" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"location" TEXT NOT NULL,
"thumbnailUrl" TEXT NOT NULL,
"link" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"metaDescription" TEXT,
"metaTitle" TEXT,
"slug" TEXT,
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Favorite" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Favorite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_resetToken_key" ON "User"("resetToken");
-- CreateIndex
CREATE UNIQUE INDEX "User_verificationToken_key" ON "User"("verificationToken");
-- CreateIndex
CREATE UNIQUE INDEX "Business_ownerId_key" ON "Business"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "Business_slug_key" ON "Business"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "BusinessCategory_name_key" ON "BusinessCategory"("name");
-- CreateIndex
CREATE UNIQUE INDEX "BusinessCategory_slug_key" ON "BusinessCategory"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Country_name_key" ON "Country"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Country_code_key" ON "Country"("code");
-- CreateIndex
CREATE UNIQUE INDEX "PricingPlan_tier_key" ON "PricingPlan"("tier");
-- CreateIndex
CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Interview_slug_key" ON "Interview"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Rating_userId_businessId_key" ON "Rating"("userId", "businessId");
-- CreateIndex
CREATE UNIQUE INDEX "ConversationParticipant_userId_conversationId_key" ON "ConversationParticipant"("userId", "conversationId");
-- CreateIndex
CREATE UNIQUE INDEX "LegalDocument_type_key" ON "LegalDocument"("type");
-- CreateIndex
CREATE UNIQUE INDEX "Event_slug_key" ON "Event"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Favorite_userId_businessId_key" ON "Favorite"("userId", "businessId");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Business" ADD CONSTRAINT "Business_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "BusinessCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Business" ADD CONSTRAINT "Business_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Business" ADD CONSTRAINT "Business_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Offer" ADD CONSTRAINT "Offer_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConversationParticipant" ADD CONSTRAINT "ConversationParticipant_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConversationParticipant" ADD CONSTRAINT "ConversationParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MessageReport" ADD CONSTRAINT "MessageReport_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MessageReport" ADD CONSTRAINT "MessageReport_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "BlogPost" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "Interview" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,11 @@
-- CreateEnum
CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- AlterTable
ALTER TABLE "BlogPost" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
-- AlterTable
ALTER TABLE "Interview" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -5,6 +5,7 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
@@ -18,10 +19,14 @@ model User {
updatedAt DateTime @updatedAt
bio String?
location String?
countryId String?
phone String?
isSuspended Boolean @default(false)
suspensionReason String?
countryId String?
resetToken String? @unique
resetTokenExpiry DateTime?
emailVerified Boolean @default(false)
verificationToken String? @unique
businesses Business?
comments Comment[]
conversations ConversationParticipant[]
@@ -29,21 +34,16 @@ model User {
reports MessageReport[]
ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
favorites Favorite[]
}
model Business {
id String @id @default(uuid())
name String
category String
suggestedCategory String?
location String // Legacy location string
countryId String?
city String?
location String
description String
logoUrl String
coverUrl String?
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
videoUrl String?
contactEmail String
contactPhone String?
@@ -68,38 +68,48 @@ model Business {
websiteUrl String?
isSuspended Boolean @default(false)
suspensionReason String?
metaTitle String?
metaDescription String?
plan Plan @default(STARTER)
city String?
countryId String?
coverUrl String?
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
suggestedCategory String?
categoryId String?
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
country Country? @relation(fields: [countryId], references: [id])
owner User @relation(fields: [ownerId], references: [id])
comments Comment[]
conversations Conversation[]
offers Offer[]
ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
categoryId String?
favorites Favorite[]
}
model BusinessCategory {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String? // Lucide icon name
icon String?
isActive Boolean @default(true)
businesses Business[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
businesses Business[]
}
model Country {
id String @id @default(uuid())
name String @unique
code String @unique // ISO alpha-2
flag String? // Emoji or URL
code String @unique
flag String?
isActive Boolean @default(true)
businesses Business[]
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
description String?
businesses Business[]
users User[]
}
model PricingPlan {
@@ -107,15 +117,15 @@ model PricingPlan {
tier Plan @unique
name String
priceXOF String
yearlyPriceXOF String?
priceEUR String
yearlyPriceEUR String?
description String
features String[]
offerLimit Int @default(1)
recommended Boolean @default(false)
color String @default("gray")
updatedAt DateTime @updatedAt
yearlyPriceEUR String?
yearlyPriceXOF String?
}
model Offer {
@@ -136,23 +146,24 @@ model Offer {
model BlogPost {
id String @id @default(uuid())
title String
slug String? @unique
excerpt String
content String
author String
date DateTime @default(now())
imageUrl String
tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metaDescription String?
metaTitle String?
slug String? @unique
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
}
model Interview {
id String @id @default(uuid())
title String
slug String? @unique
guestName String
companyName String
role String
@@ -163,11 +174,14 @@ model Interview {
excerpt String
date DateTime @default(now())
duration String?
tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metaDescription String?
metaTitle String?
slug String? @unique
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
}
model Comment {
@@ -188,40 +202,34 @@ model AnalyticsEvent {
label String?
value Float?
metadata Json?
ip String?
country String?
city String?
createdAt DateTime @default(now())
browser String?
os String?
city String?
country String?
device String?
ip String?
os String?
referrer String?
userId String?
createdAt DateTime @default(now())
}
model Rating {
id String @id @default(uuid())
value Int
comment String?
reply String?
replyAt DateTime?
status RatingStatus @default(PENDING)
userId String
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
comment String?
reply String?
replyAt DateTime?
status RatingStatus @default(PENDING)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, businessId])
}
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
model Conversation {
id String @id @default(uuid())
businessId String
@@ -266,6 +274,82 @@ model MessageReport {
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
}
model SiteSetting {
id String @id @default("singleton")
siteName String @default("Afrohub")
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
contactEmail String @default("support@afrohub.com")
contactPhone String? @default("+225 00 00 00 00 00")
address String? @default("Abidjan, Côte d'Ivoire")
facebookUrl String?
twitterUrl String?
instagramUrl String?
linkedinUrl String?
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
updatedAt DateTime @updatedAt
homeBlogCount Int @default(3)
homeBlogShow Boolean @default(true)
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
homeBlogTitle String @default("Derniers Articles")
homeBlogCategories String[] @default([])
homeBlogIds String[] @default([])
homeBlogSelection String @default("latest")
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
resetPasswordSubject String @default("Réinitialisation de votre mot de passe - Afrohub")
resetPasswordTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{resetUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">&copy; 2025 {siteName}. Tous droits réservés.</p></div>")
verifyEmailSubject String @default("Vérification de votre compte - Afrohub")
verifyEmailTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{verifyUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Vérifier mon compte</a></div><p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">&copy; 2025 {siteName}. Tous droits réservés.</p></div>")
}
model LegalDocument {
id String @id @default(uuid())
type String @unique
title String
content String
updatedAt DateTime @updatedAt
}
model Event {
id String @id @default(uuid())
title String
description String
date DateTime
location String
thumbnailUrl String
link String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metaDescription String?
metaTitle String?
slug String? @unique
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
}
model Favorite {
id String @id @default(uuid())
userId String
businessId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
@@unique([userId, businessId])
}
enum ContentStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
enum ReportStatus {
PENDING
RESOLVED
@@ -293,50 +377,3 @@ enum InterviewType {
VIDEO
ARTICLE
}
model SiteSetting {
id String @id @default("singleton")
siteName String @default("Afrohub")
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
contactEmail String @default("support@afrohub.com")
contactPhone String? @default("+225 00 00 00 00 00")
address String? @default("Abidjan, Côte d'Ivoire")
facebookUrl String?
twitterUrl String?
instagramUrl String?
linkedinUrl String?
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
homeBlogShow Boolean @default(true)
homeBlogTitle String @default("Derniers Articles")
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
homeBlogCount Int @default(3)
homeBlogSelection String @default("latest")
homeBlogIds String[] @default([])
homeBlogCategories String[] @default([])
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
updatedAt DateTime @updatedAt
}
model LegalDocument {
id String @id @default(uuid())
type String @unique // 'CGU' or 'CGV'
title String
content String @db.Text
updatedAt DateTime @updatedAt
}
model Event {
id String @id @default(uuid())
title String
slug String? @unique
description String @db.Text
date DateTime
location String
thumbnailUrl String
link String?
tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -3,6 +3,7 @@
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { generateSlug } from "@/lib/utils";
import { ContentStatus } from "@prisma/client";
export async function createBlogPost(data: {
title: string;
@@ -14,6 +15,8 @@ export async function createBlogPost(data: {
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);
@@ -23,10 +26,12 @@ export async function createBlogPost(data: {
...data,
slug,
tags: data.tags || [],
publishedAt: data.publishedAt || new Date(),
status: data.status || ContentStatus.PUBLISHED,
},
});
revalidatePath("/blog");
return { success: true, data: post };
return { success: true, id: post.id };
} catch (error) {
console.error("Failed to create blog post:", error);
return { success: false, error: "Erreur lors de la création de l'article" };
@@ -43,6 +48,8 @@ export async function updateBlogPost(id: string, data: {
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);
@@ -56,7 +63,7 @@ export async function updateBlogPost(id: string, data: {
},
});
revalidatePath("/blog");
return { success: true, data: post };
return { success: true, id: post.id };
} catch (error) {
console.error("Failed to update blog post:", error);
return { success: false, error: "Erreur lors de la mise à jour" };

View File

@@ -44,7 +44,7 @@ export async function setFeaturedBusiness(id: string, data: {
return { success: true };
} catch (error) {
console.error("Failed to set featured business:", error);
return { success: false, error: "Erreur lors de la mise à jour de l'entrepreneur du mois" };
return { success: false, error: "Erreur lors de la mise à jour de l'Afroshine" };
}
}
@@ -79,3 +79,23 @@ export async function getPendingVerificationCount() {
return 0;
}
}
export async function updateBusinessSeo(id: string, data: {
metaTitle: string;
metaDescription: string;
}) {
try {
await prisma.business.update({
where: { id },
data: {
metaTitle: data.metaTitle,
metaDescription: data.metaDescription,
},
});
revalidatePath("/users");
revalidatePath(`/annuaire/${id}`);
return { success: true };
} catch (error) {
console.error("Failed to update business SEO:", error);
return { success: false, error: "Erreur lors de la mise à jour SEO" };
}
}

View File

@@ -14,13 +14,14 @@ export async function getCountries() {
}
}
export async function addCountry(data: { name: string, code: string, flag?: string }) {
export async function addCountry(data: { name: string, code: string, flag?: string, description?: string }) {
try {
const country = await prisma.country.create({
data: {
name: data.name,
code: data.code.toUpperCase(),
flag: data.flag || null,
description: data.description || null,
isActive: true
}
});
@@ -58,3 +59,17 @@ export async function toggleCountryStatus(id: string, isActive: boolean) {
return { success: false };
}
}
export async function updateCountryDescription(id: string, description: string) {
try {
await prisma.country.update({
where: { id },
data: { description }
});
revalidatePath('/countries');
return { success: true };
} catch (error) {
console.error('Error updating country description:', error);
return { success: false, error: 'Erreur lors de la mise à jour.' };
}
}

View File

@@ -3,6 +3,7 @@
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { generateSlug } from "@/lib/utils";
import { ContentStatus } from "@prisma/client";
export async function createEvent(data: {
title: string;
@@ -15,6 +16,8 @@ export async function createEvent(data: {
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);
@@ -24,6 +27,8 @@ export async function createEvent(data: {
...data,
slug,
tags: data.tags || [],
publishedAt: data.publishedAt || new Date(),
status: data.status || ContentStatus.PUBLISHED,
},
});
revalidatePath("/blog");
@@ -46,6 +51,8 @@ export async function updateEvent(id: string, data: {
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);

View File

@@ -2,7 +2,7 @@
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { InterviewType } from "@prisma/client";
import { InterviewType, ContentStatus } from "@prisma/client";
import { generateSlug } from "@/lib/utils";
export async function createInterview(data: {
@@ -20,6 +20,8 @@ export async function createInterview(data: {
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);
@@ -29,6 +31,8 @@ export async function createInterview(data: {
...data,
slug,
tags: data.tags || [],
publishedAt: data.publishedAt || new Date(),
status: data.status || ContentStatus.PUBLISHED,
},
});
revalidatePath("/blog");
@@ -54,6 +58,8 @@ export async function updateInterview(id: string, data: {
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);

View File

@@ -28,7 +28,31 @@ export async function getSiteSettings() {
homeBlogSelection: "latest",
homeBlogIds: [],
homeBlogCategories: [],
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"]
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"],
resetPasswordSubject: "Réinitialisation de votre mot de passe - Afrohub",
resetPasswordTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
<p>Bonjour {name},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
</div>
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 {siteName}. Tous droits réservés.</p>
</div>`,
verifyEmailSubject: "Vérification de votre compte - Afrohub",
verifyEmailTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
<p>Bonjour {name},</p>
<p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
</div>
<p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p>
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 {siteName}. Tous droits réservés.</p>
</div>`
};
if (!settings) return defaultSettings;

View File

@@ -0,0 +1,138 @@
'use server';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getAllTags() {
try {
const [blogPosts, interviews, events] = await Promise.all([
prisma.blogPost.findMany({ select: { tags: true } }),
prisma.interview.findMany({ select: { tags: true } }),
prisma.event.findMany({ select: { tags: true } }),
]);
const allTags = new Set<string>();
blogPosts.forEach(p => p.tags.forEach(t => allTags.add(t)));
interviews.forEach(i => i.tags.forEach(t => allTags.add(t)));
events.forEach(e => e.tags.forEach(t => allTags.add(t)));
return Array.from(allTags).sort();
} catch (error) {
console.error('Error fetching tags:', error);
return [];
}
}
export async function deleteTagGlobally(tagName: string) {
try {
// This is expensive as we need to update all records
// In a real app, a Tag model would be better
const [blogPosts, interviews, events] = await Promise.all([
prisma.blogPost.findMany({ where: { tags: { has: tagName } } }),
prisma.interview.findMany({ where: { tags: { has: tagName } } }),
prisma.event.findMany({ where: { tags: { has: tagName } } }),
]);
await Promise.all([
...blogPosts.map(p => prisma.blogPost.update({
where: { id: p.id },
data: { tags: { set: p.tags.filter(t => t !== tagName) } }
})),
...interviews.map(i => prisma.interview.update({
where: { id: i.id },
data: { tags: { set: i.tags.filter(t => t !== tagName) } }
})),
...events.map(e => prisma.event.update({
where: { id: e.id },
data: { tags: { set: e.tags.filter(t => t !== tagName) } }
}))
]);
revalidatePath('/taxonomies');
return { success: true };
} catch (error) {
console.error('Error deleting tag globally:', error);
return { success: false, error: 'Erreur lors de la suppression du tag' };
}
}
export async function renameTagGlobally(oldName: string, newName: string) {
try {
const cleanNewName = newName.trim().toLowerCase();
if (!cleanNewName) return { success: false, error: 'Nouveau nom invalide' };
const [blogPosts, interviews, events] = await Promise.all([
prisma.blogPost.findMany({ where: { tags: { has: oldName } } }),
prisma.interview.findMany({ where: { tags: { has: oldName } } }),
prisma.event.findMany({ where: { tags: { has: oldName } } }),
]);
await Promise.all([
...blogPosts.map(p => prisma.blogPost.update({
where: { id: p.id },
data: { tags: { set: p.tags.map(t => t === oldName ? cleanNewName : t) } }
})),
...interviews.map(i => prisma.interview.update({
where: { id: i.id },
data: { tags: { set: i.tags.map(t => t === oldName ? cleanNewName : t) } }
})),
...events.map(e => prisma.event.update({
where: { id: e.id },
data: { tags: { set: e.tags.map(t => t === oldName ? cleanNewName : t) } }
}))
]);
revalidatePath('/taxonomies');
return { success: true };
} catch (error) {
console.error('Error renaming tag globally:', error);
return { success: false, error: 'Erreur lors du renommage du tag' };
}
}
export async function getTagUsage(tagName: string) {
try {
const [blogPosts, interviews, events] = await Promise.all([
prisma.blogPost.findMany({
where: { tags: { has: tagName } },
select: { id: true, title: true, status: true, author: true }
}),
prisma.interview.findMany({
where: { tags: { has: tagName } },
select: { id: true, title: true, status: true, guestName: true }
}),
prisma.event.findMany({
where: { tags: { has: tagName } },
select: { id: true, title: true, status: true, location: true }
}),
]);
return {
success: true,
data: {
blogPosts: blogPosts.map(p => ({
id: p.id,
title: p.title,
status: p.status,
type: 'blog'
})),
interviews: interviews.map(i => ({
id: i.id,
title: i.title,
status: i.status,
type: 'interview'
})),
events: events.map(e => ({
id: e.id,
title: e.title,
status: e.status,
type: 'event'
})),
}
};
} catch (error) {
console.error('Error fetching tag usage:', error);
return { success: false, error: 'Erreur lors de la récupération de l\'usage' };
}
}

View File

@@ -0,0 +1,42 @@
'use server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
export async function uploadImage(formData: FormData) {
try {
const file = formData.get('file') as File;
if (!file) {
return { success: false, error: 'Aucun fichier fourni' };
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Create a unique filename
const fileExtension = file.name.split('.').pop();
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
// Path for the ROOT public/uploads (where the main site will look)
const rootPublicDir = join(process.cwd(), '..', 'public', 'uploads');
// Path for the ADMIN public/uploads (where the admin app can see it for preview)
const adminPublicDir = join(process.cwd(), 'public', 'uploads');
// Ensure both directories exist
await mkdir(rootPublicDir, { recursive: true });
await mkdir(adminPublicDir, { recursive: true });
// Save to both
await writeFile(join(rootPublicDir, fileName), buffer);
await writeFile(join(adminPublicDir, fileName), buffer);
// Return the relative URL
return {
success: true,
url: `/uploads/${fileName}`
};
} catch (error) {
console.error('Error during image upload:', error);
return { success: false, error: 'Erreur lors de l\'upload de l\'image' };
}
}

View File

@@ -25,3 +25,19 @@ export async function getClients() {
return { success: false, error: "Erreur lors de la récupération des clients" };
}
}
export async function verifyUserEmail(userId: string) {
try {
await prisma.user.update({
where: { id: userId },
data: {
emailVerified: true,
verificationToken: null
}
});
return { success: true };
} catch (error) {
console.error("Failed to verify user email:", error);
return { success: false, error: "Erreur lors de la vérification de l'email" };
}
}

View File

@@ -12,6 +12,15 @@ async function getData() {
return { posts, interviews, events };
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'DRAFT': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-500/10 text-slate-400 border border-slate-500/20">BROUILLON</span>;
case 'PUBLISHED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">PUBLIÉ</span>;
case 'ARCHIVED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20">ARCHIVÉ</span>;
default: return null;
}
}
export default async function BlogCMSPage() {
const { posts, interviews, events } = await getData();
@@ -50,6 +59,7 @@ export default async function BlogCMSPage() {
<thead>
<tr>
<th className="text-left py-3 px-4">Titre</th>
<th className="text-left py-3 px-4">Statut</th>
<th className="text-left py-3 px-4">Auteur</th>
<th className="text-left py-3 px-4">Date</th>
<th className="text-right py-3 px-4">Actions</th>
@@ -62,6 +72,7 @@ export default async function BlogCMSPage() {
<div className="font-medium text-white">{post.title}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
</td>
<td className="py-4 px-4">{getStatusBadge(post.status)}</td>
<td className="py-4 px-4 text-sm text-slate-300">{post.author}</td>
<td className="py-4 px-4 text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
<td className="py-4 px-4 text-right">
@@ -90,6 +101,7 @@ export default async function BlogCMSPage() {
<thead>
<tr>
<th className="text-left py-3 px-4">Interviewé</th>
<th className="text-left py-3 px-4">Statut</th>
<th className="text-left py-3 px-4">Entreprise / Rôle</th>
<th className="text-left py-3 px-4">Type</th>
<th className="text-right py-3 px-4">Actions</th>
@@ -102,6 +114,7 @@ export default async function BlogCMSPage() {
<div className="font-medium text-white">{interview.guestName}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
</td>
<td className="py-4 px-4">{getStatusBadge(interview.status)}</td>
<td className="py-4 px-4">
<div className="text-sm text-slate-300">{interview.companyName}</div>
<div className="text-xs text-slate-500">{interview.role}</div>
@@ -137,6 +150,7 @@ export default async function BlogCMSPage() {
<thead>
<tr>
<th className="text-left py-3 px-4">Événement</th>
<th className="text-left py-3 px-4">Statut</th>
<th className="text-left py-3 px-4">Date & Lieu</th>
<th className="text-right py-3 px-4">Actions</th>
</tr>
@@ -150,6 +164,7 @@ export default async function BlogCMSPage() {
{event.link ? 'Lien activé' : 'Pas de lien'}
</div>
</td>
<td className="py-4 px-4">{getStatusBadge(event.status)}</td>
<td className="py-4 px-4">
<div className="flex items-center text-sm text-slate-300 gap-2">
<Calendar className="w-3 h-3 text-slate-500" />

View File

@@ -1,404 +0,0 @@
"use client";
import React, { useState, useEffect, useTransition } from 'react';
import {
Plus,
Edit2,
Trash2,
Check,
X,
Loader2,
Briefcase,
AlertCircle,
Clock,
Sparkles,
Cpu,
Sprout,
Shirt,
Utensils,
HardHat,
Stethoscope,
GraduationCap,
Palette,
Plane,
Truck,
Wallet,
Zap,
Leaf,
Camera,
Music,
ShoppingBag,
Heart,
Home
} from 'lucide-react';
import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
import { toast } from 'react-hot-toast';
const ICON_LIST = [
{ name: 'Briefcase', icon: Briefcase },
{ name: 'Cpu', icon: Cpu },
{ name: 'Sprout', icon: Sprout },
{ name: 'Shirt', icon: Shirt },
{ name: 'Sparkles', icon: Sparkles },
{ name: 'Utensils', icon: Utensils },
{ name: 'HardHat', icon: HardHat },
{ name: 'Stethoscope', icon: Stethoscope },
{ name: 'GraduationCap', icon: GraduationCap },
{ name: 'Palette', icon: Palette },
{ name: 'Plane', icon: Plane },
{ name: 'Truck', icon: Truck },
{ name: 'Wallet', icon: Wallet },
{ name: 'Zap', icon: Zap },
{ name: 'Leaf', icon: Leaf },
{ name: 'Camera', icon: Camera },
{ name: 'Music', icon: Music },
{ name: 'ShoppingBag', icon: ShoppingBag },
{ name: 'Heart', icon: Heart },
{ name: 'Home', icon: Home },
];
export default function CategoriesPage() {
const [categories, setCategories] = useState<any[]>([]);
const [suggestions, setSuggestions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isPending, startTransition] = useTransition();
const [showAddModal, setShowAddModal] = useState(false);
const [editingCategory, setEditingCategory] = useState<any>(null);
const [selectedSuggestion, setSelectedSuggestion] = useState<any>(null);
const renderIcon = (iconName: string) => {
const found = ICON_LIST.find(i => i.name === iconName);
if (found) {
const IconComp = found.icon;
return <IconComp className="w-4 h-4 text-indigo-400" />;
}
return <Briefcase className="w-4 h-4 text-indigo-400" />;
};
const [formData, setFormData] = useState({
name: '',
slug: '',
icon: 'Briefcase',
isActive: true
});
useEffect(() => {
loadCategories();
}, []);
async function loadCategories() {
setLoading(true);
try {
const [catData, suggData] = await Promise.all([
getCategories(),
getSuggestedCategories()
]);
setCategories(catData);
setSuggestions(suggData);
} catch (e) {
toast.error("Erreur de chargement");
} finally {
setLoading(false);
}
}
const handleAddCategory = async (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
const result = await createCategory({ ...formData, suggestionId: selectedSuggestion?.id });
if (result.success) {
toast.success("Catégorie créée !");
setShowAddModal(false);
setSelectedSuggestion(null);
setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
loadCategories();
} else {
toast.error(result.error || "Erreur");
}
});
};
const handleUpdateCategory = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingCategory) return;
startTransition(async () => {
const result = await updateCategory(editingCategory.id, formData);
if (result.success) {
toast.success("Catégorie mise à jour !");
setEditingCategory(null);
loadCategories();
} else {
toast.error(result.error || "Erreur");
}
});
};
const handleDelete = async (id: string) => {
if (!confirm("Supprimer cette catégorie ?")) return;
const result = await deleteCategory(id);
if (result.success) {
toast.success("Supprimée !");
loadCategories();
} else {
toast.error(result.error || "Erreur");
}
};
const openEdit = (cat: any) => {
setEditingCategory(cat);
setFormData({
name: cat.name,
slug: cat.slug,
icon: cat.icon || 'Briefcase',
isActive: cat.isActive
});
};
if (loading) {
return (
<div className="flex items-center justify-center p-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Secteurs & Catégories</h1>
<p className="text-slate-400">Gérez les secteurs d'activité disponibles pour les entrepreneurs.</p>
</div>
<button
onClick={() => { setShowAddModal(true); setEditingCategory(null); setSelectedSuggestion(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); }}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-xl flex items-center gap-2 transition-all shadow-lg shadow-indigo-600/20"
>
<Plus className="w-4 h-4" /> Nouvelle Catégorie
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center justify-between">
<h2 className="font-semibold text-white">Catégories Officielles</h2>
<span className="text-xs text-slate-400">{categories.length} secteurs</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-700">
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">Nom</th>
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">Statut</th>
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{categories.map((cat) => (
<tr key={cat.id} className="hover:bg-slate-800/30 transition-colors">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-800 rounded-lg flex items-center justify-center">
{renderIcon(cat.icon)}
</div>
<div>
<p className="font-medium text-white">{cat.name}</p>
<p className="text-[10px] text-slate-500 font-mono">{cat.slug}</p>
</div>
</div>
</td>
<td className="p-4">
{cat.isActive ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-500">
<Check className="w-3 h-3" /> Actif
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/10 text-slate-500">
<X className="w-3 h-3" /> Inactif
</span>
)}
</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => openEdit(cat)}
className="p-2 text-slate-400 hover:text-indigo-400 hover:bg-indigo-400/10 rounded-lg transition-all"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(cat.id)}
className="p-2 text-slate-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-400" />
<h2 className="font-semibold text-white">Suggestions</h2>
</div>
<div className="p-4 space-y-4">
{suggestions.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-3">
<Check className="w-6 h-6 text-slate-600" />
</div>
<p className="text-sm text-slate-500">Aucune suggestion en attente.</p>
</div>
) : (
suggestions.map((sugg) => (
<div key={sugg.id} className="p-4 bg-slate-950 rounded-xl border border-slate-800 space-y-2 hover:border-indigo-500/30 transition-colors group">
<div className="flex justify-between items-start">
<span className="text-[10px] font-bold text-indigo-400 uppercase tracking-wider bg-indigo-400/10 px-2 py-0.5 rounded">Proposé</span>
<span className="text-[10px] text-slate-500">{new Date(sugg.createdAt).toLocaleDateString()}</span>
</div>
<p className="text-white font-bold text-lg">{sugg.suggestedCategory}</p>
<div className="flex items-center gap-2 text-xs text-slate-400">
<Briefcase className="w-3 h-3" />
<span>Par : <span className="text-slate-300 font-medium">{sugg.name}</span></span>
</div>
<button
onClick={() => {
setSelectedSuggestion(sugg);
setFormData({
name: sugg.suggestedCategory,
slug: sugg.suggestedCategory.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ /g, '-').replace(/[^\w-]/g, ''),
icon: 'Briefcase',
isActive: true
});
setShowAddModal(true);
}}
className="w-full mt-3 text-xs bg-indigo-600/10 text-indigo-400 hover:bg-indigo-600 hover:text-white font-bold py-2.5 rounded-lg transition-all border border-indigo-600/20"
>
Convertir en catégorie officielle
</button>
</div>
))
)}
</div>
</div>
</div>
</div>
{(showAddModal || editingCategory) && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/30">
<div className="flex items-center gap-3">
{selectedSuggestion && <Sparkles className="w-5 h-5 text-yellow-400" />}
<h3 className="text-xl font-bold text-white">
{editingCategory ? 'Modifier la catégorie' : selectedSuggestion ? 'Valider la suggestion' : 'Nouvelle catégorie'}
</h3>
</div>
<button
onClick={() => { setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
className="text-slate-400 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={editingCategory ? handleUpdateCategory : handleAddCategory} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom de la catégorie</label>
<input
required
autoFocus
value={formData.name}
onChange={(e) => {
const name = e.target.value;
const slug = name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ /g, '-').replace(/[^\w-]/g, '');
setFormData({...formData, name, slug});
}}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Slug (URL)</label>
<input
required
value={formData.slug}
onChange={(e) => setFormData({...formData, slug: e.target.value})}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 font-mono text-sm"
/>
</div>
<div className="flex items-center gap-3 p-3 bg-slate-950 rounded-xl border border-slate-800">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) => setFormData({...formData, isActive: e.target.checked})}
className="w-5 h-5 rounded border-slate-700 bg-slate-800 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="isActive" className="text-sm font-medium text-slate-300">Rendre active immédiatement</label>
</div>
{selectedSuggestion && (
<div className="p-4 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-xs text-indigo-300">
<p>✨ **Note :** En créant cette catégorie, l'entreprise **{selectedSuggestion.name}** y sera automatiquement rattachée.</p>
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 block mb-2">Choisir une icône</label>
<div className="grid grid-cols-5 gap-2 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
{ICON_LIST.map((item) => (
<button
key={item.name}
type="button"
onClick={() => setFormData({...formData, icon: item.name})}
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${
formData.icon === item.name
? 'bg-indigo-600 border-indigo-500 text-white shadow-lg shadow-indigo-600/30 scale-105'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-600'
}`}
title={item.name}
>
<item.icon className="w-5 h-5" />
</button>
))}
</div>
</div>
</div>
<div className="pt-4 flex gap-3">
<button
type="button"
onClick={() => { setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
className="flex-1 bg-slate-800 hover:bg-slate-700 text-white font-bold py-3 rounded-xl transition-all border border-slate-700"
>
Annuler
</button>
<button
type="submit"
disabled={isPending}
className="flex-1 bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white font-bold py-3 rounded-xl transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20"
>
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{editingCategory ? 'Sauvegarder les modifications' : selectedSuggestion ? 'Valider et Créer' : 'Créer la catégorie'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { getClients } from "@/app/actions/user";
import { User, Mail, Phone, MapPin } from "lucide-react";
import ClientActions from "@/components/ClientActions";
export default async function ClientsPage() {
const result = await getClients();
const clients = result.success ? (result.data || []) : [];
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Clients</h1>
<p className="text-slate-400">Liste des utilisateurs n'ayant pas encore de boutique active.</p>
</div>
<div className="bg-slate-800/50 px-4 py-2 rounded-lg border border-slate-700/50">
<span className="text-slate-400 text-sm">Total Clients: </span>
<span className="text-indigo-400 font-bold ml-1">{clients.length}</span>
</div>
</div>
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Utilisateur</th>
<th>Contact</th>
<th>Localisation</th>
<th>Statut</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{clients.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-500">
<div className="flex flex-col items-center gap-2">
<User className="w-8 h-8 opacity-20" />
<p>Aucun client trouvé.</p>
</div>
</td>
</tr>
) : (
clients.map((client: any) => (
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
<td className="py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
{client.avatar ? (
<img src={client.avatar} alt={client.name} className="w-full h-full object-cover" />
) : (
client.name.charAt(0)
)}
</div>
<div>
<p className="text-white font-semibold">{client.name}</p>
<p className="text-slate-500 text-xs">ID: {client.id.split('-')[0]}...</p>
</div>
</div>
</td>
<td>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Mail className="w-3 h-3 text-slate-500" />
{client.email}
</div>
{client.phone && (
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Phone className="w-3 h-3 text-slate-500" />
{client.phone}
</div>
)}
</div>
</td>
<td>
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<MapPin className="w-3 h-3" />
{client.location || "Non spécifié"}
</div>
</td>
<td>
<div className="flex flex-col gap-1">
{client.isSuspended ? (
<>
<span className="badge badge-error">Compte Suspendu</span>
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
{client.suspensionReason}
</span>
</>
) : client.businesses ? (
<span className="badge badge-pending">Shop Inactif</span>
) : (
<span className="badge bg-slate-800 text-slate-400">Actif</span>
)}
</div>
</td>
<td className="text-right">
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,14 +1,16 @@
"use client";
import React, { useState, useEffect, useTransition } from 'react';
import { getCountries, addCountry, deleteCountry, toggleCountryStatus } from '@/app/actions/countries';
import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag } from 'lucide-react';
import { getCountries, addCountry, deleteCountry, toggleCountryStatus, updateCountryDescription } from '@/app/actions/countries';
import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag, Edit2, Check, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function CountriesPage() {
const [countries, setCountries] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editDesc, setEditDesc] = useState("");
const [isPending, startTransition] = useTransition();
const loadData = async () => {
@@ -27,9 +29,10 @@ export default function CountriesPage() {
const name = formData.get('name') as string;
const code = formData.get('code') as string;
const flag = formData.get('flag') as string;
const description = formData.get('description') as string;
startTransition(async () => {
const result = await addCountry({ name, code, flag });
const result = await addCountry({ name, code, flag, description });
if (result.success) {
toast.success("Pays ajouté avec succès");
setIsAdding(false);
@@ -55,10 +58,22 @@ export default function CountriesPage() {
const handleToggleStatus = async (id: string, current: boolean) => {
const result = await toggleCountryStatus(id, !current);
if (result.success) {
toast.success(!current ? "Pays activé" : "Pays désactivé");
loadData();
}
};
const handleSaveDescription = async (id: string) => {
const result = await updateCountryDescription(id, editDesc);
if (result.success) {
toast.success("Description mise à jour");
setEditingId(null);
loadData();
} else {
toast.error("Erreur lors de la mise à jour");
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-20">
@@ -85,7 +100,8 @@ export default function CountriesPage() {
{isAdding && (
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl p-6 mb-8 animate-in fade-in slide-in-from-top-2">
<h2 className="text-lg font-bold text-white mb-4">Nouveau Pays</h2>
<form onSubmit={handleAdd} className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<form onSubmit={handleAdd} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du pays</label>
<input name="name" required placeholder="Ex: Sénégal" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
@@ -98,13 +114,20 @@ export default function CountriesPage() {
<label className="text-sm font-medium text-slate-400">Emoji Drapeau / URL Icone</label>
<input name="flag" placeholder="Ex: 🇸🇳" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
</div>
<div className="flex gap-2">
<button disabled={isPending} type="submit" className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white p-3 rounded-xl font-bold transition-all disabled:opacity-50">
{isPending ? 'Chargement...' : 'Enregistrer'}
</button>
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-700 text-white p-3 rounded-xl font-bold transition-all">
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Note / Description (interne)</label>
<textarea name="description" placeholder="Ajouter un commentaire sur ce pays..." rows={2} className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-700 text-white px-6 py-3 rounded-xl font-bold transition-all">
Annuler
</button>
<button disabled={isPending} type="submit" className="bg-emerald-600 hover:bg-emerald-700 text-white px-10 py-3 rounded-xl font-bold transition-all disabled:opacity-50">
{isPending ? 'Chargement...' : 'Enregistrer'}
</button>
</div>
</form>
</div>
@@ -116,6 +139,7 @@ export default function CountriesPage() {
<tr>
<th className="px-6 py-4">Pays</th>
<th className="px-6 py-4">Code</th>
<th className="px-6 py-4">Description / Notes</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4 text-right">Actions</th>
</tr>
@@ -123,7 +147,7 @@ export default function CountriesPage() {
<tbody className="divide-y divide-slate-800">
{countries.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-10 text-center text-slate-500">Aucun pays configuré.</td>
<td colSpan={5} className="px-6 py-10 text-center text-slate-500">Aucun pays configuré.</td>
</tr>
) : countries.map((country) => (
<tr key={country.id} className="hover:bg-slate-800/30 transition-colors">
@@ -134,15 +158,55 @@ export default function CountriesPage() {
</div>
</td>
<td className="px-6 py-4 font-mono text-indigo-400">{country.code}</td>
<td className="px-6 py-4">
{editingId === country.id ? (
<div className="flex items-center gap-2">
<input
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
className="bg-slate-950 border border-slate-700 rounded-lg px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500 w-full"
autoFocus
/>
<button onClick={() => handleSaveDescription(country.id)} className="text-emerald-500 hover:text-emerald-400">
<Check className="w-4 h-4" />
</button>
<button onClick={() => setEditingId(null)} className="text-slate-500 hover:text-slate-400">
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="group flex items-center gap-2 max-w-xs">
<span className="text-slate-400 text-sm truncate">
{country.description || <span className="italic text-slate-600">Pas de note</span>}
</span>
<button
onClick={() => {
setEditingId(country.id);
setEditDesc(country.description || "");
}}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-white transition-opacity"
>
<Edit2 className="w-3 h-3" />
</button>
</div>
)}
</td>
<td className="px-6 py-4">
<button
onClick={() => handleToggleStatus(country.id, country.isActive)}
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold ${
country.isActive ? 'bg-emerald-500/10 text-emerald-500' : 'bg-slate-500/10 text-slate-500'
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
country.isActive ? 'bg-indigo-600' : 'bg-slate-700'
}`}
>
{country.isActive ? 'Actif' : 'Inactif'}
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
country.isActive ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className={`ml-3 text-xs font-bold ${country.isActive ? 'text-emerald-500' : 'text-slate-500'}`}>
{country.isActive ? 'ACTIF' : 'INACTIF'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button onClick={() => handleDelete(country.id, country.name)} className="p-2 text-slate-500 hover:text-red-500 transition-colors">

View File

@@ -0,0 +1,68 @@
"use client";
import React from 'react';
import {
Users,
Store,
Clock,
MessageCircle,
Eye
} from 'lucide-react';
interface Stats {
usersCount: number;
businessCount: number;
pendingCount: number;
commentsCount: number;
totalViews: number;
uniqueVisitors: number;
}
export default function DashboardClient({ stats }: { stats: Stats }) {
const statCards = [
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
];
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
</div>
<div className="stats-grid">
{statCards.map((card) => (
<div key={card.label} className="card">
<div className="flex justify-between items-start mb-4">
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
<card.icon className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">{card.value}</span>
</div>
<h3 className="text-slate-400 font-medium">{card.label}</h3>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,5 @@
import { prisma } from '@/lib/prisma';
import {
Users,
Store,
Clock,
MessageCircle,
Eye
} from 'lucide-react';
import DashboardClient from './DashboardClient';
async function getStats() {
const usersCount = await prisma.user.count();
@@ -32,51 +26,5 @@ async function getStats() {
export default async function DashboardPage() {
const stats = await getStats();
const statCards = [
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
];
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
</div>
<div className="stats-grid">
{statCards.map((card) => (
<div key={card.label} className="card">
<div className="flex justify-between items-start mb-4">
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
<card.icon className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">{card.value}</span>
</div>
<h3 className="text-slate-400 font-medium">{card.label}</h3>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
</div>
</div>
);
return <DashboardClient stats={stats} />;
}

View File

@@ -1,145 +0,0 @@
import { prisma } from '@/lib/prisma';
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
import FeaturedModal from '@/components/FeaturedModal';
import EntrepreneurActions from '@/components/EntrepreneurActions';
import PlanSelector from '@/components/PlanSelector';
import { ShieldAlert, ShieldX, ShieldCheck } from 'lucide-react';
async function getBusinesses() {
return await prisma.business.findMany({
where: {
isActive: true
},
include: {
owner: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
export default async function EntrepreneursPage() {
const businesses = await getBusinesses();
const pendingCount = businesses.filter((b: any) =>
!b.verified &&
!b.isSuspended &&
['BOOSTER', 'EMPIRE'].includes(b.plan)
).length;
return (
<div>
<div className="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Entrepreneurs</h1>
<p className="text-slate-400">Gérer les abonnements et certifier les comptes (Badge Bleu).</p>
</div>
{pendingCount > 0 && (
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-3 rounded-2xl flex items-center gap-4 animate-pulse">
<div className="w-10 h-10 bg-blue-500 rounded-xl flex items-center justify-center text-white">
<ShieldCheck className="w-6 h-6" />
</div>
<div>
<div className="text-blue-400 font-bold">{pendingCount} en attente</div>
<div className="text-xs text-blue-400/80 font-medium uppercase tracking-wider">Certifications Booster</div>
</div>
</div>
)}
</div>
<div className="card overflow-hidden p-0">
<table className="data-table">
<thead>
<tr>
<th>Entreprise</th>
<th>Catégorie</th>
<th>Propriétaire</th>
<th>Statut</th>
<th className="text-center">Forfait</th>
<th className="text-center">Visibilité</th>
<th className="text-center">Mise en avant</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody>
{businesses.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-10 text-slate-500">
Aucun entrepreneur trouvé.
</td>
</tr>
) : (
businesses.map((business: any) => (
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
<td>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
{business.logoUrl ? (
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
) : (
<span className="text-xs text-slate-500">LOGO</span>
)}
</div>
<div>
<div className="font-semibold text-white">{business.name}</div>
<div className="text-xs text-slate-500">{business.location}</div>
</div>
</div>
</td>
<td>
<span className="text-sm text-slate-300">{business.category}</span>
</td>
<td>
<div className="text-sm text-white">{business.owner.name}</div>
<div className="text-xs text-slate-500">{business.owner.email}</div>
</td>
<td>
<div className="flex flex-col gap-1">
{business.owner?.isSuspended ? (
<span className="badge badge-error flex items-center gap-1">
<ShieldX className="w-3 h-3" /> Compte Suspendu
</span>
) : business.isSuspended ? (
<span className="badge bg-orange-500/10 text-orange-500 border border-orange-500/20 flex items-center gap-1">
<ShieldAlert className="w-3 h-3" /> Shop Masqué
</span>
) : business.verified ? (
<span className="badge badge-verified">Vérifié</span>
) : (
<span className="badge badge-pending">En attente</span>
)}
</div>
</td>
<td className="text-center">
<PlanSelector businessId={business.id} currentPlan={business.plan} />
</td>
<td className="text-center font-medium text-slate-300">
{business.viewCount.toLocaleString()}
</td>
<td className="text-center">
<FeaturedModal business={business} />
</td>
<td className="text-right">
<div className="flex items-center justify-end gap-3">
<ToggleVerifyButton id={business.id} verified={business.verified} />
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
<EntrepreneurActions
businessId={business.id}
businessName={business.name}
userId={business.owner.id}
userName={business.owner.name}
isBusinessSuspended={!!business.isSuspended}
isUserSuspended={!!business.owner?.isSuspended}
/>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -265,6 +265,13 @@ body {
font-style: normal;
}
.quill-dark-wrapper .ql-editor {
text-align: justify;
hyphens: auto;
word-break: normal;
line-height: 1.6;
}
/* Styles for the Divider (HR) */
.quill-dark-wrapper .ql-editor hr {
border: none;

View File

@@ -0,0 +1,38 @@
"use client";
import React from 'react';
import Link from 'next/link';
import { LayoutDashboard, ArrowLeft } from 'lucide-react';
export default function NotFound() {
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6">
<div className="max-w-md w-full text-center">
<div className="mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 mb-6">
<span className="text-4xl font-bold text-indigo-400">404</span>
</div>
<h1 className="text-3xl font-bold text-white mb-2">Page d'administration introuvable</h1>
<p className="text-slate-400">La ressource que vous recherchez n'existe pas ou a é déplacée.</p>
</div>
<div className="space-y-3">
<Link
href="/dashboard"
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition-colors"
>
<LayoutDashboard className="w-5 h-5" />
Retour au Dashboard
</Link>
<button
onClick={() => window.history.back()}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-slate-900 text-slate-300 border border-slate-800 rounded-xl font-semibold hover:bg-slate-800 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
Page précédente
</button>
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Ne
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
import { toast } from 'react-hot-toast';
import { getBlogPosts } from '@/app/actions/blog';
import RichTextEditor from '@/components/RichTextEditor';
const BUSINESS_CATEGORIES = [
"Technologie & IT",
@@ -33,7 +34,11 @@ const BLOG_CATEGORIES = [
"Formation"
];
export default function SettingsPage() {
export default function SettingsPage({
searchParams
}: {
searchParams: Promise<{ tab?: string }>
}) {
const [isPending, startTransition] = useTransition();
const [settings, setSettings] = useState<any>(null);
const [allPosts, setAllPosts] = useState<any[]>([]);
@@ -41,6 +46,12 @@ export default function SettingsPage() {
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
const [resetPasswordTemplate, setResetPasswordTemplate] = useState('');
const [verifyEmailTemplate, setVerifyEmailTemplate] = useState('');
// Use a state for the tab if we want it to be fully client-side reactive without full reload
// But since the user wants it "like moderation center", I'll use URL search params
const [currentTab, setCurrentTab] = useState('general');
useEffect(() => {
async function loadData() {
@@ -53,6 +64,8 @@ export default function SettingsPage() {
setSelectedPostIds(settingsData?.homeBlogIds || []);
setSelectedHomeCategories(settingsData?.homeCategories || []);
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || '');
setVerifyEmailTemplate(settingsData?.verifyEmailTemplate || '');
setLoading(false);
}
loadData();
@@ -80,6 +93,10 @@ export default function SettingsPage() {
homeBlogIds: selectedPostIds,
homeBlogCategories: selectedBlogCategories,
homeCategories: selectedHomeCategories,
resetPasswordSubject: formData.get('resetPasswordSubject'),
resetPasswordTemplate: resetPasswordTemplate,
verifyEmailSubject: formData.get('verifyEmailSubject'),
verifyEmailTemplate: verifyEmailTemplate,
};
startTransition(async () => {
@@ -123,11 +140,31 @@ export default function SettingsPage() {
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Configuration du Site</h1>
<p className="text-slate-400">Gérez les informations globales et les coordonnées de contact du site principal.</p>
<p className="text-slate-400">Gérez les informations globales, les sections de l'accueil et les modèles d'emails.</p>
</div>
</div>
{/* Tabs Switcher */}
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
<button
onClick={() => setCurrentTab('general')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'general' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Globe className="w-4 h-4" />
Général
</button>
<button
onClick={() => setCurrentTab('emails')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'emails' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Mail className="w-4 h-4" />
Configuration Mail
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{currentTab === 'general' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Section Générale */}
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
@@ -358,7 +395,7 @@ export default function SettingsPage() {
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Facebook URL</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="facebookUrl"
defaultValue={settings?.facebookUrl}
@@ -370,7 +407,7 @@ export default function SettingsPage() {
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="twitterUrl"
defaultValue={settings?.twitterUrl}
@@ -382,7 +419,7 @@ export default function SettingsPage() {
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Instagram URL</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="instagramUrl"
defaultValue={settings?.instagramUrl}
@@ -394,7 +431,7 @@ export default function SettingsPage() {
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">LinkedIn URL</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="linkedinUrl"
defaultValue={settings?.linkedinUrl}
@@ -405,6 +442,71 @@ export default function SettingsPage() {
</div>
</div>
</div>
</div>
)}
{currentTab === 'emails' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Section Mail */}
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
<Mail className="w-5 h-5 text-orange-400" />
<h2 className="font-semibold text-white">Gestion des Emails</h2>
</div>
<div className="p-6 space-y-8">
{/* Password Reset */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">Réinitialisation de mot de passe</h3>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
<input
name="resetPasswordSubject"
defaultValue={settings?.resetPasswordSubject}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
<RichTextEditor
value={resetPasswordTemplate}
onChange={setResetPasswordTemplate}
placeholder="Contenu de l'email..."
/>
<p className="text-[10px] text-slate-500">
Variables : <code className="text-indigo-400">{"{name}"}</code>, <code className="text-indigo-400">{"{resetUrl}"}</code>, <code className="text-indigo-400">{"{siteName}"}</code>
</p>
</div>
</div>
<div className="border-t border-slate-800"></div>
{/* Account Verification */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider">Vérification de compte</h3>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
<input
name="verifyEmailSubject"
defaultValue={settings?.verifyEmailSubject}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
<RichTextEditor
value={verifyEmailTemplate}
onChange={setVerifyEmailTemplate}
placeholder="Contenu de l'email..."
/>
<p className="text-[10px] text-slate-500">
Variables : <code className="text-indigo-400">{"{name}"}</code>, <code className="text-indigo-400">{"{verifyUrl}"}</code>, <code className="text-indigo-400">{"{siteName}"}</code>
</p>
</div>
</div>
</div>
</div>
</div>
)}
<div className="flex justify-end">
<button
@@ -424,3 +526,4 @@ export default function SettingsPage() {
</div>
);
}

View File

@@ -0,0 +1,415 @@
"use client";
import React, { useState, useEffect, useTransition } from 'react';
import {
Plus,
Edit2,
Trash2,
Check,
X,
Loader2,
Briefcase,
AlertCircle,
Clock,
Sparkles,
Cpu,
Sprout,
Shirt,
Utensils,
HardHat,
Stethoscope,
GraduationCap,
Palette,
Plane,
Truck,
Wallet,
Zap,
Leaf,
Camera,
Music,
ShoppingBag,
Heart,
Home,
Hash,
Tags,
Search,
ExternalLink,
Eye,
FileText,
User as UserIcon,
MapPin,
BookOpen,
Save
} from 'lucide-react';
import Link from 'next/link';
import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
import { getAllTags, deleteTagGlobally, renameTagGlobally, getTagUsage } from '@/app/actions/taxonomies';
import { toast } from 'react-hot-toast';
const ICON_LIST = [
{ name: 'Briefcase', icon: Briefcase },
{ name: 'Cpu', icon: Cpu },
{ name: 'Sprout', icon: Sprout },
{ name: 'Shirt', icon: Shirt },
{ name: 'Sparkles', icon: Sparkles },
{ name: 'Utensils', icon: Utensils },
{ name: 'HardHat', icon: HardHat },
{ name: 'Stethoscope', icon: Stethoscope },
{ name: 'GraduationCap', icon: GraduationCap },
{ name: 'Palette', icon: Palette },
{ name: 'Plane', icon: Plane },
{ name: 'Truck', icon: Truck },
{ name: 'Wallet', icon: Wallet },
{ name: 'Zap', icon: Zap },
{ name: 'Leaf', icon: Leaf },
{ name: 'Camera', icon: Camera },
{ name: 'Music', icon: Music },
{ name: 'ShoppingBag', icon: ShoppingBag },
{ name: 'Heart', icon: Heart },
{ name: 'Home', icon: Home },
{ name: 'Tags', icon: Tags },
];
type Tab = 'categories' | 'tags';
export default function TaxonomiesClient() {
const [activeTab, setActiveTab] = useState<Tab>('categories');
const [categories, setCategories] = useState<any[]>([]);
const [suggestions, setSuggestions] = useState<any[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [isPending, startTransition] = useTransition();
// Category Form State
const [showAddModal, setShowAddModal] = useState(false);
const [editingCategory, setEditingCategory] = useState<any>(null);
const [formData, setFormData] = useState({
name: '',
slug: '',
icon: 'Briefcase',
isActive: true
});
// Tag Form State
const [newTagName, setNewTagName] = useState('');
const [isEditingUsageName, setIsEditingUsageName] = useState(false);
const [usagePreview, setUsagePreview] = useState<{ tag: string, data: any } | null>(null);
const [loadingUsage, setLoadingUsage] = useState(false);
const loadData = async () => {
setLoading(true);
try {
const [catsRes, tagsRes, sugRes] = await Promise.all([
getCategories(),
getAllTags(),
getSuggestedCategories()
]);
setCategories(catsRes);
setTags(tagsRes);
setSuggestions(sugRes);
} catch (err) {
toast.error("Erreur de chargement");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleCategorySubmit = async (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
const result = editingCategory
? await updateCategory(editingCategory.id, formData)
: await createCategory(formData);
if (result.success) {
toast.success(editingCategory ? "Catégorie mise à jour" : "Catégorie créée");
setShowAddModal(false);
setEditingCategory(null);
setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
loadData();
} else {
toast.error(result.error || "Erreur");
}
});
};
const handleTagDelete = async (tag: string) => {
if (!confirm(`Supprimer le tag "${tag}" globalement ?`)) return;
const result = await deleteTagGlobally(tag);
if (result.success) {
toast.success("Tag supprimé partout");
loadData();
} else {
toast.error(result.error || "Erreur");
}
};
const handleShowUsage = async (tag: string) => {
setLoadingUsage(true);
const result = await getTagUsage(tag);
if (result.success) {
setUsagePreview({ tag, data: result.data });
}
setLoadingUsage(false);
};
if (loading) return <div className="p-8 text-center text-slate-500">Chargement...</div>;
return (
<div className="space-y-8">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Taxonomies</h1>
<p className="text-slate-400">Gérez les catégories et les mots-clés du site.</p>
</div>
<div className="flex bg-slate-900 p-1 rounded-xl border border-slate-800">
<button
onClick={() => setActiveTab('categories')}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'categories' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
>
Catégories
</button>
<button
onClick={() => setActiveTab('tags')}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'tags' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
>
Tags / Mots-clés
</button>
</div>
</div>
{activeTab === 'categories' ? (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Tags className="w-5 h-5 text-indigo-400" />
Liste des catégories ({categories.length})
</h2>
<button
onClick={() => { setEditingCategory(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); setShowAddModal(true); }}
className="btn-verify flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Nouvelle catégorie
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map((cat) => {
const IconComp = ICON_LIST.find(i => i.name === cat.icon)?.icon || Briefcase;
return (
<div key={cat.id} className="card p-4 flex items-center justify-between group">
<div className="flex items-center gap-4">
<div className="p-2 bg-slate-800 rounded-lg text-indigo-400">
<IconComp className="w-5 h-5" />
</div>
<div>
<h3 className="text-white font-bold">{cat.name}</h3>
<p className="text-xs text-slate-500">/{cat.slug}</p>
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => { setEditingCategory(cat); setFormData({ name: cat.name, slug: cat.slug, icon: cat.icon, isActive: cat.isActive }); setShowAddModal(true); }}
className="p-2 text-slate-400 hover:text-indigo-400 transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={async () => { if (confirm("Supprimer ?")) await deleteCategory(cat.id); loadData(); }}
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
) : (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Hash className="w-5 h-5 text-indigo-400" />
Tags utilisés ({tags.length})
</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{tags.map((tag) => (
<div
key={tag}
onClick={() => handleShowUsage(tag)}
className="group flex items-center justify-between bg-slate-950 border border-slate-800 rounded-xl p-3 hover:border-indigo-500/50 transition-all hover:bg-indigo-500/5 cursor-pointer"
>
<span className="text-sm font-bold text-slate-300 truncate group-hover:text-white">{tag}</span>
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<Trash2
className="w-3.5 h-3.5 text-slate-500 hover:text-red-400"
onClick={(e) => { e.stopPropagation(); handleTagDelete(tag); }}
/>
</div>
</div>
))}
</div>
</div>
)}
{/* Modal Ajout/Modif Catégorie */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<div className="bg-slate-900 border border-slate-700 rounded-3xl p-8 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-6">{editingCategory ? 'Modifier la catégorie' : 'Nouvelle catégorie'}</h3>
<form onSubmit={handleCategorySubmit} className="space-y-4">
<div>
<label className="block text-sm font-bold text-slate-400 mb-2 uppercase tracking-wider">Nom</label>
<input
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-white focus:border-indigo-500 outline-none transition-all"
/>
</div>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto p-1">
{ICON_LIST.map((item) => (
<button
key={item.name}
type="button"
onClick={() => setFormData({ ...formData, icon: item.name })}
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${formData.icon === item.name ? 'bg-indigo-600/20 border-indigo-500 text-indigo-400' : 'bg-slate-950 border-slate-800 text-slate-500 hover:border-slate-700'}`}
>
<item.icon className="w-5 h-5" />
</button>
))}
</div>
<div className="flex gap-3 mt-8">
<button type="button" onClick={() => setShowAddModal(false)} className="flex-1 px-4 py-3 bg-slate-800 text-white rounded-xl font-bold hover:bg-slate-700 transition-all">Annuler</button>
<button type="submit" disabled={isPending} className="flex-1 px-4 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all flex items-center justify-center gap-2">
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
{editingCategory ? 'Mettre à jour' : 'Créer'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Usage Modal */}
{usagePreview && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<div className="bg-slate-900 border border-slate-700 rounded-3xl w-full max-w-4xl overflow-hidden shadow-2xl">
<div className="p-6 border-b border-slate-700 bg-slate-800/30 flex justify-between items-center">
<div className="flex items-center gap-3">
<Hash className="w-6 h-6 text-indigo-500" />
{isEditingUsageName ? (
<div className="flex items-center gap-2">
<input
autoFocus
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
className="bg-slate-950 border border-indigo-500/50 rounded-xl px-4 py-2 text-white font-bold"
/>
<button
onClick={async () => {
const res = await renameTagGlobally(usagePreview.tag, newTagName);
if (res.success) {
toast.success("Renommé");
setUsagePreview({ ...usagePreview, tag: newTagName });
setIsEditingUsageName(false);
loadData();
}
}}
className="p-2 bg-emerald-600 text-white rounded-lg"
>
<Check className="w-4 h-4" />
</button>
</div>
) : (
<h3 className="text-xl font-bold text-white uppercase tracking-tight">Tag : {usagePreview.tag}</h3>
)}
</div>
<div className="flex items-center gap-4">
{!isEditingUsageName && (
<button onClick={() => { setNewTagName(usagePreview.tag); setIsEditingUsageName(true); }} className="p-2 text-slate-400 hover:text-indigo-400"><Edit2 className="w-4 h-4" /></button>
)}
<button onClick={() => setUsagePreview(null)} className="p-2 text-slate-400 hover:text-white"><X className="w-6 h-6" /></button>
</div>
</div>
<div className="p-8 grid grid-cols-1 md:grid-cols-3 gap-6 max-h-[60vh] overflow-y-auto custom-scrollbar">
{/* Blog Posts */}
<div className="space-y-4">
<div className="flex items-center gap-2 px-1">
<div className="p-1.5 bg-indigo-500/10 rounded-lg">
<BookOpen className="w-3.5 h-3.5 text-indigo-400" />
</div>
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Articles de Blog ({usagePreview.data.blogPosts.length})</h4>
</div>
<div className="space-y-2">
{usagePreview.data.blogPosts.length === 0 ? (
<p className="text-[10px] text-slate-600 italic px-2">Aucun article</p>
) : usagePreview.data.blogPosts.map((post: any) => (
<div key={post.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-indigo-500/30 transition-all">
<span className="text-sm font-medium text-slate-200 truncate pr-2">{post.title}</span>
<Link href={`/blog/edit/${post.id}`} className="p-1.5 bg-slate-800 hover:bg-indigo-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
<Edit2 className="w-3 h-3" />
</Link>
</div>
))}
</div>
</div>
{/* Interviews */}
<div className="space-y-4">
<div className="flex items-center gap-2 px-1">
<div className="p-1.5 bg-emerald-500/10 rounded-lg">
<UserIcon className="w-3.5 h-3.5 text-emerald-400" />
</div>
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Interviews ({usagePreview.data.interviews.length})</h4>
</div>
<div className="space-y-2">
{usagePreview.data.interviews.length === 0 ? (
<p className="text-[10px] text-slate-600 italic px-2">Aucune interview</p>
) : usagePreview.data.interviews.map((item: any) => (
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-emerald-500/30 transition-all">
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
<Link href={`/interview/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-emerald-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
<Edit2 className="w-3 h-3" />
</Link>
</div>
))}
</div>
</div>
{/* Events */}
<div className="space-y-4">
<div className="flex items-center gap-2 px-1">
<div className="p-1.5 bg-amber-500/10 rounded-lg">
<Clock className="w-3.5 h-3.5 text-amber-400" />
</div>
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Événements ({usagePreview.data.events.length})</h4>
</div>
<div className="space-y-2">
{usagePreview.data.events.length === 0 ? (
<p className="text-[10px] text-slate-600 italic px-2">Aucun événement</p>
) : usagePreview.data.events.map((item: any) => (
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-amber-500/30 transition-all">
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
<Link href={`/event/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-amber-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
<Edit2 className="w-3 h-3" />
</Link>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import TaxonomiesClient from "./TaxonomiesClient";
export default function TaxonomiesPage() {
return <TaxonomiesClient />;
}

View File

@@ -0,0 +1,300 @@
import { prisma } from '@/lib/prisma';
import { getClients } from "@/app/actions/user";
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
import FeaturedModal from '@/components/FeaturedModal';
import EntrepreneurActions from '@/components/EntrepreneurActions';
import PlanSelector from '@/components/PlanSelector';
import ClientActions from "@/components/ClientActions";
import ManualVerifyButton from "@/components/ManualVerifyButton";
import { ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
import Link from 'next/link';
async function getBusinesses() {
return await prisma.business.findMany({
where: {
isActive: true
},
include: {
owner: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
export default async function UsersPage({
searchParams
}: {
searchParams: Promise<{ tab?: string }>
}) {
const { tab = 'entrepreneurs' } = await searchParams;
// Data for Entrepreneurs
const businesses = await getBusinesses();
const pendingCount = businesses.filter((b: any) =>
!b.verified &&
!b.isSuspended &&
['BOOSTER', 'EMPIRE'].includes(b.plan)
).length;
// Data for Clients/Visitors
const clientsResult = await getClients();
const clients = (clientsResult.success ? (clientsResult.data || []) : []) as any[];
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Utilisateurs</h1>
<p className="text-slate-400">Gérer les entrepreneurs et les visiteurs de la plateforme.</p>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
<Link
href="/users?tab=entrepreneurs"
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'entrepreneurs' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Briefcase className="w-4 h-4" />
Entrepreneurs ({businesses.length})
{pendingCount > 0 && (
<span className="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full">
{pendingCount}
</span>
)}
</Link>
<Link
href="/users?tab=visiteurs"
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'visiteurs' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<User className="w-4 h-4" />
Visiteurs ({clients.length})
</Link>
</div>
{tab === 'entrepreneurs' ? (
<div className="space-y-6">
{pendingCount > 0 && (
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-3 rounded-2xl flex items-center gap-4 animate-pulse max-w-fit">
<div className="w-10 h-10 bg-blue-500 rounded-xl flex items-center justify-center text-white">
<ShieldCheck className="w-6 h-6" />
</div>
<div>
<div className="text-blue-400 font-bold">{pendingCount} en attente</div>
<div className="text-xs text-blue-400/80 font-medium uppercase tracking-wider">Certifications Booster</div>
</div>
</div>
)}
<div className="card overflow-hidden p-0">
<table className="data-table">
<thead>
<tr>
<th>Entreprise</th>
<th>Catégorie</th>
<th>Propriétaire</th>
<th>Email</th>
<th>Statut</th>
<th className="text-center">Forfait</th>
<th className="text-center">Mise en avant</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody>
{businesses.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-10 text-slate-500">
Aucun entrepreneur trouvé.
</td>
</tr>
) : (
businesses.map((business: any) => (
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
<td>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
{business.logoUrl ? (
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
) : (
<span className="text-xs text-slate-500">LOGO</span>
)}
</div>
<div>
<div className="font-semibold text-white">{business.name}</div>
<div className="text-xs text-slate-500">{business.location}</div>
</div>
</div>
</td>
<td>
<span className="text-sm text-slate-300">{business.category}</span>
</td>
<td>
<div className="text-sm text-white">{business.owner.name}</div>
<div className="text-xs text-slate-500">{business.owner.email}</div>
</td>
<td>
{business.owner.emailVerified ? (
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
<MailCheck className="w-3.5 h-3.5" /> Vérifié
</span>
) : (
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
<MailX className="w-3.5 h-3.5" /> Non vérifié
</span>
<ManualVerifyButton userId={business.owner.id} />
</div>
)}
</td>
<td>
<div className="flex flex-col gap-1">
{business.owner?.isSuspended ? (
<span className="badge badge-error flex items-center gap-1">
<ShieldX className="w-3 h-3" /> Compte Suspendu
</span>
) : business.isSuspended ? (
<span className="badge bg-orange-500/10 text-orange-500 border border-orange-500/20 flex items-center gap-1">
<ShieldAlert className="w-3 h-3" /> Shop Masqué
</span>
) : business.verified ? (
<span className="badge badge-verified">Vérifié</span>
) : (
<span className="badge badge-pending">En attente</span>
)}
</div>
</td>
<td className="text-center">
<PlanSelector businessId={business.id} currentPlan={business.plan} />
</td>
<td className="text-center">
<FeaturedModal business={business} />
</td>
<td className="text-right">
<div className="flex items-center justify-end gap-3">
<ToggleVerifyButton id={business.id} verified={business.verified} />
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
<EntrepreneurActions
businessId={business.id}
businessName={business.name}
userId={business.owner.id}
userName={business.owner.name}
isBusinessSuspended={!!business.isSuspended}
isUserSuspended={!!business.owner?.isSuspended}
metaTitle={business.metaTitle}
metaDescription={business.metaDescription}
/>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Utilisateur</th>
<th>Contact</th>
<th>Vérification</th>
<th>Localisation</th>
<th>Statut</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{clients.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-slate-500">
<div className="flex flex-col items-center gap-2">
<Users className="w-8 h-8 opacity-20" />
<p>Aucun visiteur trouvé.</p>
</div>
</td>
</tr>
) : (
clients.map((client: any) => (
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
<td className="py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
{client.avatar ? (
<img src={client.avatar} alt={client.name} className="w-full h-full object-cover" />
) : (
client.name.charAt(0)
)}
</div>
<div>
<p className="text-white font-semibold">{client.name}</p>
<p className="text-slate-500 text-xs">ID: {client.id.split('-')[0]}...</p>
</div>
</div>
</td>
<td>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Mail className="w-3 h-3 text-slate-500" />
{client.email}
</div>
{client.phone && (
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Phone className="w-3 h-3 text-slate-500" />
{client.phone}
</div>
)}
</div>
</td>
<td>
{client.emailVerified ? (
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
<MailCheck className="w-3.5 h-3.5" /> Vérifié
</span>
) : (
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
<MailX className="w-3.5 h-3.5" /> Non vérifié
</span>
<ManualVerifyButton userId={client.id} />
</div>
)}
</td>
<td>
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<MapPin className="w-3 h-3" />
{client.location || "Non spécifié"}
</div>
</td>
<td>
<div className="flex flex-col gap-1">
{client.isSuspended ? (
<>
<span className="badge badge-error">Compte Suspendu</span>
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
{client.suspensionReason}
</span>
</>
) : client.businesses ? (
<span className="badge badge-pending">Shop Inactif</span>
) : (
<span className="badge bg-slate-800 text-slate-400">Actif</span>
)}
</div>
</td>
<td className="text-right">
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,12 +1,16 @@
"use client";
import { useTransition, useState } from 'react';
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
import { Loader2, ArrowLeft, Save } from 'lucide-react';
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
import ImageUploader from './ImageUploader';
import TagInput from './TagInput';
import { ContentStatus } from '@prisma/client';
import { getAllTags } from '@/app/actions/taxonomies';
interface Props {
initialData?: {
@@ -20,6 +24,8 @@ interface Props {
tags?: string[];
metaTitle?: string | null;
metaDescription?: string | null;
publishedAt?: Date | string | null;
status?: ContentStatus;
};
}
@@ -27,29 +33,52 @@ export default function BlogForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || '');
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
initialData?.publishedAt
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
);
const isPublished = new Date(publishedAtValue) <= new Date();
useEffect(() => {
getAllTags().then(setAllExistingTags);
}, []);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const publishedAtStr = formData.get('publishedAt') as string;
const data = {
title: formData.get('title') as string,
slug: formData.get('slug') as string,
excerpt: formData.get('excerpt') as string,
content: content,
author: formData.get('author') as string,
imageUrl: formData.get('imageUrl') as string,
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
imageUrl: imageUrl, // Use state value
tags: tags, // Use state value
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
};
if (!data.imageUrl) {
toast.error("Une image est requise");
return;
}
startTransition(async () => {
const result = initialData
? await updateBlogPost(initialData.id, data)
: await createBlogPost(data);
if (result.success) {
toast.success(initialData ? "Article mis à jour" : "Article publié avec succès");
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
router.push('/blog');
router.refresh();
} else {
@@ -73,7 +102,21 @@ export default function BlogForm({ initialData }: Props) {
<form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
<div className="flex items-center gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
name="status"
defaultValue={initialData?.status || 'PUBLISHED'}
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
>
<option value="DRAFT">Brouillon</option>
<option value="PUBLISHED">Publié</option>
<option value="ARCHIVED">Archivé</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
@@ -98,16 +141,31 @@ export default function BlogForm({ initialData }: Props) {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de l'image</label>
<input
<ImageUploader
label="Image de couverture"
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
defaultValue={initialData?.imageUrl}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://images.unsplash.com/..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
</label>
<div className="relative">
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
<input
name="publishedAt"
type="datetime-local"
value={publishedAtValue}
onChange={(e) => setPublishedAtValue(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
@@ -147,12 +205,12 @@ export default function BlogForm({ initialData }: Props) {
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
<input
name="tags"
defaultValue={initialData?.tags?.join(', ') || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="tech, afrique, innovation"
<TagInput
value={tags}
onChange={setTags}
suggestions={allExistingTags}
label="Mots-clés (tags)"
placeholder="tech, innovation, afrique..."
/>
</div>
</div>
@@ -190,7 +248,7 @@ export default function BlogForm({ initialData }: Props) {
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Publier l\'article'}
{initialData ? 'Enregistrer les modifications' : 'Créer l\'article'}
</button>
</div>
</form>

View File

@@ -0,0 +1,120 @@
"use client";
import React, { useState, useTransition } from 'react';
import { X, Save, Loader2, Globe } from 'lucide-react';
import { updateBusinessSeo } from '@/app/actions/business';
import { toast } from 'react-hot-toast';
interface BusinessSeoModalProps {
isOpen: boolean;
onClose: () => void;
business: {
id: string;
name: string;
metaTitle?: string | null;
metaDescription?: string | null;
};
}
export default function BusinessSeoModal({ isOpen, onClose, business }: BusinessSeoModalProps) {
const [isPending, startTransition] = useTransition();
const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
const [metaDescription, setMetaDescription] = useState(business.metaDescription || '');
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
const result = await updateBusinessSeo(business.id, {
metaTitle,
metaDescription
});
if (result.success) {
toast.success("Configuration SEO mise à jour");
onClose();
} else {
toast.error(result.error || "Erreur lors de la mise à jour");
}
});
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
<div className="p-6 border-b border-slate-800 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-500">
<Globe className="w-6 h-6" />
</div>
<div>
<h3 className="text-xl font-bold text-white">SEO & Partage</h3>
<p className="text-xs text-slate-400">Personnaliser l'aperçu WhatsApp pour {business.name}</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Titre (WhatsApp/Google)</label>
<input
value={metaTitle}
onChange={(e) => setMetaTitle(e.target.value)}
placeholder={`Ex: ${business.name} - Expert en ...`}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-all"
/>
<p className="text-[10px] text-slate-500 italic">Recommandé : Moins de 60 caractères.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Description</label>
<textarea
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
rows={4}
placeholder="Décrivez l'entreprise en quelques mots pour attirer les clics..."
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-all resize-none"
/>
<p className="text-[10px] text-slate-500 italic">Recommandé : Entre 140 et 160 caractères.</p>
</div>
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-xl p-4">
<h4 className="text-[10px] font-bold text-indigo-400 uppercase tracking-widest mb-2">Aperçu du partage</h4>
<div className="bg-slate-950 rounded-lg p-3 border border-slate-800">
<div className="text-sm font-bold text-indigo-400 line-clamp-1">{metaTitle || business.name}</div>
<div className="text-xs text-slate-500 line-clamp-2 mt-1">{metaDescription || "Découvrez cette entreprise sur Afroprenariat..."}</div>
<div className="text-[10px] text-slate-600 mt-2">afroprenariat.com</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 rounded-xl border border-slate-800 text-slate-400 font-bold hover:bg-slate-800 transition-all"
>
Annuler
</button>
<button
type="submit"
disabled={isPending}
className="flex-[2] py-3 rounded-xl bg-indigo-600 text-white font-bold hover:bg-indigo-500 transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20"
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Save className="w-5 h-5" />
)}
Enregistrer
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,10 +1,11 @@
"use client";
import React, { useState } from 'react';
import { ShieldAlert, ShieldCheck, ShoppingBag, User } from 'lucide-react';
import { ShieldAlert, ShieldCheck, ShoppingBag, User, Globe } from 'lucide-react';
import { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } from '@/app/actions/suspension';
import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal';
import BusinessSeoModal from './BusinessSeoModal';
interface EntrepreneurActionsProps {
businessId: string;
@@ -13,6 +14,8 @@ interface EntrepreneurActionsProps {
userName: string;
isBusinessSuspended: boolean;
isUserSuspended: boolean;
metaTitle?: string | null;
metaDescription?: string | null;
}
export default function EntrepreneurActions({
@@ -21,9 +24,12 @@ export default function EntrepreneurActions({
userId,
userName,
isBusinessSuspended,
isUserSuspended
isUserSuspended,
metaTitle,
metaDescription
}: EntrepreneurActionsProps) {
const [modalType, setModalType] = useState<'USER' | 'BUSINESS' | null>(null);
const [seoModalOpen, setSeoModalOpen] = useState(false);
const handleConfirm = async (reason: string) => {
if (modalType === 'USER') {
@@ -41,6 +47,16 @@ export default function EntrepreneurActions({
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 justify-end">
{/* SEO Button */}
<button
onClick={() => setSeoModalOpen(true)}
className="btn-action bg-indigo-500/10 text-indigo-500 hover:bg-indigo-500 hover:text-white"
title="Modifier le SEO / Partage WhatsApp"
>
<Globe className="w-4 h-4" />
<span className="text-[10px] uppercase font-bold">SEO</span>
</button>
{/* User Suspension */}
{isUserSuspended ? (
<button
@@ -93,6 +109,17 @@ export default function EntrepreneurActions({
? `Voulez-vous suspendre TOUT le compte de ${userName} ? Sa boutique sera également masquée.`
: `Voulez-vous masquer la boutique "${businessName}" ? L'entrepreneur gardera l'accès à son compte.`}
/>
<BusinessSeoModal
isOpen={seoModalOpen}
onClose={() => setSeoModalOpen(false)}
business={{
id: businessId,
name: businessName,
metaTitle,
metaDescription
}}
/>
</div>
);
}

View File

@@ -1,12 +1,16 @@
"use client";
import { useTransition, useState } from 'react';
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createEvent, updateEvent } from '@/app/actions/event';
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon } from 'lucide-react';
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
import ImageUploader from './ImageUploader';
import TagInput from './TagInput';
import { ContentStatus } from '@prisma/client';
import { getAllTags } from '@/app/actions/taxonomies';
interface Props {
initialData?: any;
@@ -16,23 +20,46 @@ export default function EventForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [description, setDescription] = useState(initialData?.description || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
initialData?.publishedAt
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
);
const isPublished = new Date(publishedAtValue) <= new Date();
useEffect(() => {
getAllTags().then(setAllExistingTags);
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const publishedAtStr = formData.get('publishedAt') as string;
const data: any = {
title: formData.get('title') as string,
slug: formData.get('slug') as string,
location: formData.get('location') as string,
date: new Date(formData.get('date') as string),
thumbnailUrl: formData.get('thumbnailUrl') as string,
thumbnailUrl: thumbnailUrl, // Use state value
link: formData.get('link') as string || null,
description: description || '',
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
tags: tags, // Use state
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
};
if (!data.thumbnailUrl) {
toast.error("Une image est requise");
return;
}
startTransition(async () => {
const result = initialData
? await updateEvent(initialData.id, data)
@@ -63,7 +90,21 @@ export default function EventForm({ initialData }: Props) {
<form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
<div className="flex items-center gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
name="status"
defaultValue={initialData?.status || 'PUBLISHED'}
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
>
<option value="DRAFT">Brouillon</option>
<option value="PUBLISHED">Publié</option>
<option value="ARCHIVED">Archivé</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
@@ -77,7 +118,7 @@ export default function EventForm({ initialData }: Props) {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Date de l'événement</label>
<label className="text-sm font-medium text-slate-400">Date de l'événement (Début)</label>
<div className="relative">
<Calendar className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
@@ -105,6 +146,32 @@ export default function EventForm({ initialData }: Props) {
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
</label>
<div className="relative">
<Send className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
name="publishedAt"
type="datetime-local"
value={publishedAtValue}
onChange={(e) => setPublishedAtValue(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<ImageUploader
label="Image (Affiche/Miniature)"
value={thumbnailUrl}
onChange={setThumbnailUrl}
name="thumbnailUrl"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Lien d'inscription / info (Optionnel)</label>
<div className="relative">
@@ -119,17 +186,6 @@ export default function EventForm({ initialData }: Props) {
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de l'image (Affiche/Miniature)</label>
<input
name="thumbnailUrl"
defaultValue={initialData?.thumbnailUrl}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Description de l'événement</label>
<RichTextEditor
@@ -156,12 +212,12 @@ export default function EventForm({ initialData }: Props) {
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
<input
name="tags"
defaultValue={initialData?.tags?.join(', ') || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="evenement, networking, afrique"
<TagInput
value={tags}
onChange={setTags}
suggestions={allExistingTags}
label="Mots-clés (tags)"
placeholder="evenement, networking, afrique..."
/>
</div>
</div>

View File

@@ -32,7 +32,7 @@ export default function FeaturedModal({ business }: Props) {
startTransition(async () => {
const result = await setFeaturedBusiness(business.id, data);
if (result.success) {
toast.success("Entrepreneur du mois mis à jour !");
toast.success("Afroshine mis à jour !");
setIsOpen(false);
} else {
toast.error(result.error || "Une erreur est survenue");
@@ -41,7 +41,7 @@ export default function FeaturedModal({ business }: Props) {
};
const handleRemove = async () => {
if (confirm("Retirer cet entrepreneur du titre 'Entrepreneur du mois' ?")) {
if (confirm("Retirer cet entrepreneur du titre 'Afroshine' ?")) {
startTransition(async () => {
const result = await removeFeaturedBusiness(business.id);
if (result.success) {
@@ -79,7 +79,7 @@ export default function FeaturedModal({ business }: Props) {
</button>
<div className="mb-6">
<h2 className="text-2xl font-bold text-white mb-2">💰 Entrepreneur du Mois</h2>
<h2 className="text-2xl font-bold text-white mb-2">💰 Afroshine</h2>
<p className="text-slate-400">Configurez les détails pour {business.name}.</p>
</div>
@@ -124,7 +124,7 @@ export default function FeaturedModal({ business }: Props) {
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
Définir comme Entrepreneur du Mois
Définir comme Afroshine
</button>
{business.isFeatured && (

View File

@@ -0,0 +1,174 @@
'use client';
import React, { useState, useRef } from 'react';
import { Upload, Link as LinkIcon, X, Image as ImageIcon, Loader2 } from 'lucide-react';
import { uploadImage } from '@/app/actions/upload';
import { toast } from 'react-hot-toast';
interface Props {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
name?: string;
}
export default function ImageUploader({ value, onChange, label, placeholder, name }: Props) {
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Basic validation
if (!file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image");
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error("L'image ne doit pas dépasser 5 Mo");
return;
}
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
const result = await uploadImage(formData);
if (result.success && result.url) {
onChange(result.url);
toast.success("Image uploadée avec succès");
} else {
toast.error(result.error || "Erreur lors de l'upload");
}
} catch (error) {
toast.error("Erreur de connexion lors de l'upload");
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const clearImage = () => {
onChange('');
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-400">{label || "Image"}</label>
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-800">
<button
type="button"
onClick={() => setMode('url')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
mode === 'url' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
<LinkIcon className="w-3.5 h-3.5" />
Lien URL
</button>
<button
type="button"
onClick={() => setMode('upload')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
mode === 'upload' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
<Upload className="w-3.5 h-3.5" />
Upload
</button>
</div>
</div>
<div className="relative group">
{mode === 'url' ? (
<div className="relative">
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
type="text"
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "https://images.unsplash.com/..."}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
) : (
<div className="space-y-4">
{!value ? (
<div
onClick={() => fileInputRef.current?.click()}
className="cursor-pointer border-2 border-dashed border-slate-700 hover:border-indigo-500 rounded-xl p-8 flex flex-col items-center justify-center gap-3 bg-slate-900/50 hover:bg-slate-900 transition-all group"
>
{isUploading ? (
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
) : (
<div className="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center group-hover:bg-indigo-500/20 group-hover:text-indigo-400 transition-colors">
<Upload className="w-6 h-6 text-slate-400 group-hover:text-indigo-400" />
</div>
)}
<div className="text-center">
<p className="text-sm font-medium text-white">
{isUploading ? "Upload en cours..." : "Cliquez pour uploader"}
</p>
<p className="text-xs text-slate-500 mt-1">PNG, JPG ou WEBP jusqu'à 5 Mo</p>
</div>
</div>
) : (
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
<img
src={value}
alt="Prévisualisation"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
>
<Upload className="w-5 h-5" />
</button>
<button
type="button"
onClick={clearImage}
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Hidden input for value to be sent via form if needed */}
<input type="hidden" name={name} value={value} />
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
</div>
)}
</div>
{value && mode === 'url' && (
<div className="mt-3 relative rounded-lg overflow-hidden aspect-video border border-slate-800">
<img src={value} alt="Preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={clearImage}
className="absolute top-2 right-2 p-1.5 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-black transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}

View File

@@ -1,13 +1,16 @@
"use client";
import { useTransition, useState } from 'react';
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createInterview, updateInterview } from '@/app/actions/interview';
import { Loader2, ArrowLeft, Save, Video, FileText } from 'lucide-react';
import { Loader2, ArrowLeft, Save, Video, FileText, Calendar } from 'lucide-react';
import Link from 'next/link';
import { InterviewType } from '@prisma/client';
import { InterviewType, ContentStatus } from '@prisma/client';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
import ImageUploader from './ImageUploader';
import TagInput from './TagInput';
import { getAllTags } from '@/app/actions/taxonomies';
interface Props {
initialData?: any;
@@ -17,10 +20,26 @@ export default function InterviewForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
initialData?.publishedAt
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
);
const isPublished = new Date(publishedAtValue) <= new Date();
useEffect(() => {
getAllTags().then(setAllExistingTags);
}, []);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const publishedAtStr = formData.get('publishedAt') as string;
const data: any = {
title: formData.get('title') as string,
slug: formData.get('slug') as string,
@@ -28,16 +47,23 @@ export default function InterviewForm({ initialData }: Props) {
companyName: formData.get('companyName') as string,
role: formData.get('role') as string,
type: formData.get('type') as InterviewType,
thumbnailUrl: formData.get('thumbnailUrl') as string,
thumbnailUrl: thumbnailUrl, // Use state value
videoUrl: formData.get('videoUrl') as string || null,
excerpt: formData.get('excerpt') as string,
content: content || null,
duration: formData.get('duration') as string || null,
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
tags: tags, // Use state
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
};
if (!data.thumbnailUrl) {
toast.error("Une miniature est requise");
return;
}
startTransition(async () => {
const result = initialData
? await updateInterview(initialData.id, data)
@@ -68,7 +94,21 @@ export default function InterviewForm({ initialData }: Props) {
<form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
<div className="flex items-center gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
name="status"
defaultValue={initialData?.status || 'PUBLISHED'}
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
>
<option value="DRAFT">Brouillon</option>
<option value="PUBLISHED">Publié</option>
<option value="ARCHIVED">Archivé</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
@@ -126,24 +166,29 @@ export default function InterviewForm({ initialData }: Props) {
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
<label className="text-sm font-medium text-slate-400">
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
</label>
<div className="relative">
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
<input
name="duration"
defaultValue={initialData?.duration}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: 12 min"
name="publishedAt"
type="datetime-local"
value={publishedAtValue}
onChange={(e) => setPublishedAtValue(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de la miniature (Thumbnail)</label>
<input
<ImageUploader
label="Miniature (Thumbnail)"
value={thumbnailUrl}
onChange={setThumbnailUrl}
name="thumbnailUrl"
defaultValue={initialData?.thumbnailUrl}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
@@ -154,6 +199,15 @@ export default function InterviewForm({ initialData }: Props) {
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://youtube.com/..."
/>
<div className="space-y-2 mt-4">
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
<input
name="duration"
defaultValue={initialData?.duration}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: 12 min"
/>
</div>
</div>
</div>
@@ -194,12 +248,12 @@ export default function InterviewForm({ initialData }: Props) {
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
<input
name="tags"
defaultValue={initialData?.tags?.join(', ') || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="interview, succes, tech"
<TagInput
value={tags}
onChange={setTags}
suggestions={allExistingTags}
label="Mots-clés (tags)"
placeholder="tech, succes, interview..."
/>
</div>
</div>

View File

@@ -0,0 +1,41 @@
"use client";
import { useTransition } from 'react';
import { verifyUserEmail } from '@/app/actions/user';
import { MailCheck, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface Props {
userId: string;
}
export default function ManualVerifyButton({ userId }: Props) {
const [isPending, startTransition] = useTransition();
const handleVerify = () => {
startTransition(async () => {
const result = await verifyUserEmail(userId);
if (result.success) {
toast.success("Email vérifié avec succès");
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
return (
<button
onClick={handleVerify}
disabled={isPending}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-500/10 text-indigo-500 hover:bg-indigo-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-indigo-500/20"
title="Vérifier manuellement l'email"
>
{isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<MailCheck className="w-3 h-3" />
)}
Vérifier Email
</button>
);
}

View File

@@ -15,19 +15,19 @@ import {
Settings,
Globe,
FileText,
Briefcase
Briefcase,
Tags
} from 'lucide-react';
const menuItems = [
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
{ name: 'Entrepreneurs', icon: ShieldCheck, href: '/entrepreneurs' },
{ name: 'Clients', icon: Users, href: '/clients' },
{ name: 'Utilisateurs', icon: Users, href: '/users' },
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
{ name: 'Modération', icon: Flag, href: '/moderation' },
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
{ name: 'Secteurs & Catégories', icon: Briefcase, href: '/categories' },
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
{ name: 'Pays', icon: Globe, href: '/countries' },
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
{ name: 'Configuration', icon: Settings, href: '/settings' },
@@ -67,7 +67,7 @@ export default function Sidebar() {
<span>{item.name}</span>
</div>
{item.name === 'Entrepreneurs' && pendingCount > 0 && (
{item.name === 'Utilisateurs' && pendingCount > 0 && (
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shadow-lg group-hover:scale-110 transition-transform">
{pendingCount}
</span>

View File

@@ -0,0 +1,180 @@
'use client';
import React, { useState, KeyboardEvent, useEffect, useRef } from 'react';
import { X, Hash, Search, TrendingUp } from 'lucide-react';
interface Props {
value: string[];
onChange: (value: string[]) => void;
label?: string;
placeholder?: string;
suggestions?: string[];
}
export default function TagInput({ value, onChange, label, placeholder, suggestions = [] }: Props) {
const [inputValue, setInputValue] = useState('');
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inputValue.trim()) {
const filtered = suggestions
.filter(tag =>
tag.toLowerCase().includes(inputValue.toLowerCase()) &&
!value.includes(tag.toLowerCase())
)
.slice(0, 10);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else if (showSuggestions && suggestions.length > 0) {
// Show popular/all suggestions when empty but focused
const filtered = suggestions
.filter(tag => !value.includes(tag.toLowerCase()))
.slice(0, 8);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else {
setFilteredSuggestions([]);
setShowSuggestions(false);
}
setSelectedIndex(-1);
}, [inputValue, suggestions, value, showSuggestions]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const addTag = (tagToAdd?: string) => {
const tag = (tagToAdd || inputValue).trim().toLowerCase();
if (tag && !value.includes(tag)) {
onChange([...value, tag]);
}
setInputValue('');
// Keep focus if we added via suggestion
if (tagToAdd) setShowSuggestions(true);
};
const removeTag = (tagToRemove: string) => {
onChange(value.filter(tag => tag !== tagToRemove));
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
addTag(filteredSuggestions[selectedIndex]);
} else {
addTag();
}
} else if (e.key === ',' || e.key === ';') {
e.preventDefault();
addTag();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (!showSuggestions && suggestions.length > 0) {
setShowSuggestions(true);
} else {
setSelectedIndex(prev => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev));
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => (prev > -1 ? prev - 1 : -1));
} else if (e.key === 'Escape') {
setShowSuggestions(false);
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
removeTag(value[value.length - 1]);
}
};
return (
<div className="space-y-2 relative" ref={containerRef}>
{label && <label className="text-sm font-medium text-slate-400">{label}</label>}
<div
className="min-h-[48px] p-1.5 bg-slate-900 border border-slate-700 rounded-lg flex flex-wrap gap-2 items-center focus-within:border-indigo-500 transition-colors cursor-text"
onClick={() => {
const input = containerRef.current?.querySelector('input');
input?.focus();
}}
>
{value.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 px-2.5 py-1 rounded-md text-sm font-medium animate-in zoom-in-95 duration-200"
>
<Hash className="w-3 h-3 opacity-50" />
{tag}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
className="hover:text-red-400 transition-colors ml-1"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(true)}
placeholder={value.length === 0 ? (placeholder || "Ajouter des tags...") : ""}
className="flex-1 bg-transparent border-none outline-none text-white text-sm min-w-[120px] p-1 placeholder:text-slate-600"
/>
</div>
{/* Suggestions Popover */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-b border-slate-700 bg-slate-900/50 flex items-center justify-between">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest flex items-center gap-1.5">
{inputValue ? <Search className="w-3 h-3" /> : <TrendingUp className="w-3 h-3" />}
{inputValue ? 'Suggestions de recherche' : 'Mots-clés suggérés'}
</p>
{!inputValue && <span className="text-[9px] text-slate-600 font-medium">Top {filteredSuggestions.length}</span>}
</div>
<div className="max-h-60 overflow-y-auto custom-scrollbar">
{filteredSuggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
onClick={(e) => {
e.stopPropagation();
addTag(suggestion);
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
index === selectedIndex ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700'
}`}
>
<span className="flex items-center gap-2">
<Hash className={`w-3.5 h-3.5 ${index === selectedIndex ? 'text-white/50' : 'text-slate-500'}`} />
{suggestion}
</span>
{index === selectedIndex && (
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded font-bold uppercase">Entrée</span>
)}
</button>
))}
</div>
</div>
)}
<p className="text-[10px] text-slate-500 italic">
Appuyez sur Entrée ou virgule pour ajouter un tag.
</p>
</div>
);
}

170
app/HomeClient.tsx Normal file
View File

@@ -0,0 +1,170 @@
"use client";
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
import { generateSlug } from '@/lib/utils';
import { Business, BlogPost } from '@/types';
import BusinessCard from '@/components/BusinessCard';
import { useUser } from '@/components/UserProvider';
interface Props {
initialFeatured: Business[];
initialPosts: BlogPost[];
initialCategories: any[];
}
const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props) => {
const router = useRouter();
const { user, settings } = useUser();
const [searchTerm, setSearchTerm] = useState('');
const [locationTerm, setLocationTerm] = useState('');
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
const [categories, setCategories] = useState<any[]>(initialCategories);
const [loading, setLoading] = useState(false);
const [loadingPosts, setLoadingPosts] = useState(false);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const params = new URLSearchParams();
if (searchTerm) params.append('q', searchTerm);
if (locationTerm) params.append('location', locationTerm);
router.push(`/annuaire?${params.toString()}`);
};
return (
<div>
{/* Hero Section */}
<div className="relative bg-dark-900 overflow-hidden">
<div className="absolute inset-0 opacity-40">
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" width={1740} height={1160} />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
Boostez votre visibilité dans <br />
<span className="text-brand-500">l'écosystème africain</span>
</h1>
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
</p>
<form onSubmit={handleSearch} className="max-w-3xl bg-white p-2 rounded-lg shadow-xl flex flex-col md:flex-row gap-2">
<div className="flex-1 relative">
<Search className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Que recherchez-vous ? (ex: Développeur, Traiteur...)"
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
<MapPin className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Localisation (ex: Abidjan)"
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
value={locationTerm}
onChange={(e) => setLocationTerm(e.target.value)}
/>
</div>
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
Rechercher
</button>
</form>
</div>
</div>
{/* Featured Categories */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<h2 className="text-3xl font-bold text-gray-900 mb-10 font-serif text-center">Secteurs en vedette</h2>
<div className="flex flex-wrap justify-center gap-4 sm:gap-6">
{(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
<Link
key={idx}
href={`/annuaire?category=${encodeURIComponent(cat)}`}
className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center w-[calc(50%-0.5rem)] sm:w-48 lg:w-56 flex flex-col items-center"
>
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
<Briefcase className="w-6 h-6" />
</div>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base line-clamp-2">{cat}</h3>
</Link>
))}
</div>
</div>
{/* Featured Businesses */}
<div className="bg-gray-50 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">Entreprises à la une</h2>
<p className="text-gray-500 mt-2">Découvrez les pépites de notre communauté.</p>
</div>
<Link href="/annuaire" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{featuredBusinesses.map(biz => (
<BusinessCard key={biz.id} business={biz} />
))}
</div>
</div>
</div>
{/* Blog Section */}
{settings?.homeBlogShow !== false && (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
</div>
<Link href="/blog" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout le blog <ArrowRight className="w-4 h-4 ml-1" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts
.filter(post => {
if (settings?.homeBlogSelection === 'manual') {
return settings.homeBlogIds?.includes(post.id);
}
if (settings?.homeBlogSelection === 'category') {
return post.tags?.some((tag: string) => settings.homeBlogCategories?.includes(tag));
}
return true;
})
.slice(0, settings?.homeBlogCount || 3)
.map(post => (
<Link key={post.id} href={`/blog/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
<div className="h-48 overflow-hidden">
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
</div>
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-brand-600 transition-colors">{post.title}</h3>
<p className="text-gray-500 text-sm line-clamp-3 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between mt-auto pt-4 border-t border-gray-50">
<span className="text-xs font-medium text-gray-400">{new Date(post.date).toLocaleDateString('fr-FR')}</span>
<span className="text-brand-600 font-bold text-xs">Lire la suite </span>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default HomeClient;

28
app/actions/upload.ts Normal file
View File

@@ -0,0 +1,28 @@
'use server';
export async function uploadImage(formData: FormData) {
try {
const file = formData.get('file') as File;
if (!file) {
return { success: false, error: 'Aucun fichier fourni' };
}
// Convert file to buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Convert to Base64
const base64 = buffer.toString('base64');
const mimeType = file.type || 'image/jpeg';
const dataUrl = `data:${mimeType};base64,${base64}`;
// Return the data URL directly (it will be saved in the database)
return {
success: true,
url: dataUrl
};
} catch (error) {
console.error('Error during image conversion:', error);
return { success: false, error: 'Erreur lors de la conversion de l\'image' };
}
}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight } from 'lucide-react';
import { sanitizeHTML } from '../../../lib/sanitize';
import { prisma } from '../../../lib/prisma';
import { InterviewType } from '@prisma/client';
@@ -13,16 +14,21 @@ interface Props {
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const now = new Date();
const interview = await prisma.interview.findFirst({
where: {
OR: [{ id }, { slug: id }]
OR: [{ id }, { slug: id }],
publishedAt: { lte: now },
status: 'PUBLISHED'
}
});
const event = !interview ? await prisma.event.findFirst({
where: {
OR: [{ id }, { slug: id }]
OR: [{ id }, { slug: id }],
publishedAt: { lte: now },
status: 'PUBLISHED'
}
}) : null;
@@ -46,18 +52,23 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function AfroLifeDetailPage({ params }: Props) {
const { id } = await params;
const now = new Date();
// 1. Try to find an interview
const interview = await prisma.interview.findFirst({
where: {
OR: [{ id }, { slug: id }]
OR: [{ id }, { slug: id }],
publishedAt: { lte: now },
status: 'PUBLISHED'
}
});
// 2. If not found, try to find an event
const event = !interview ? await prisma.event.findFirst({
where: {
OR: [{ id }, { slug: id }]
OR: [{ id }, { slug: id }],
publishedAt: { lte: now },
status: 'PUBLISHED'
}
}) : null;
@@ -156,12 +167,12 @@ export default async function AfroLifeDetailPage({ params }: Props) {
)}
{/* Description / Article Content / Event Content */}
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600 break-words overflow-hidden">
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600">
{isEvent ? (
<div dangerouslySetInnerHTML={{ __html: (event as any).description }} />
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} />
) : (
!isVideo && (interview as any).content ? (
<div dangerouslySetInnerHTML={{ __html: (interview as any).content }} />
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((interview as any).content) }} />
) : (
<p className="text-xl font-serif italic text-gray-500 border-l-4 border-brand-500 pl-6 py-2">
{(interview as any).excerpt}
@@ -194,4 +205,3 @@ export default async function AfroLifeDetailPage({ params }: Props) {
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import Link from 'next/link';
import { Play, FileText, Clock, Mic, Calendar } from 'lucide-react';
import { sanitizeHTML } from '../../lib/sanitize';
import { prisma } from '../../lib/prisma';
import { InterviewType } from '@prisma/client';
import EventTimeline from '../../components/EventTimeline';
@@ -13,13 +14,22 @@ interface Props {
export default async function AfroLifePage({ searchParams }: Props) {
const params = await searchParams;
const filter = params.type as string || 'ALL';
const rawFilter = params.type as string || 'ALL';
const allowedFilters = ['ALL', 'EVENT', 'VIDEO', 'ARTICLE'];
const filter = allowedFilters.includes(rawFilter) ? rawFilter : 'ALL';
let items: any[] = [];
const now = new Date();
try {
if (filter === 'EVENT') {
const events = await prisma.event.findMany({
where: {
status: 'PUBLISHED',
publishedAt: {
lte: now
}
},
orderBy: { date: 'desc' }
});
items = events.map(e => ({
@@ -31,7 +41,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
duration: new Date(e.date).toLocaleDateString('fr-FR')
}));
} else {
const where: any = {};
const where: any = {
status: 'PUBLISHED',
publishedAt: {
lte: now
}
};
if (filter !== 'ALL') {
where.type = filter as InterviewType;
}
@@ -44,6 +59,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
// If 'ALL', we might want to also fetch events and mix them
if (filter === 'ALL') {
const events = await prisma.event.findMany({
where: {
status: 'PUBLISHED',
publishedAt: {
lte: now
}
},
orderBy: { date: 'desc' },
take: 10
});
@@ -167,7 +188,7 @@ export default async function AfroLifePage({ searchParams }: Props) {
<h3 className={`text-xl font-serif font-bold mb-2 transition-colors leading-tight line-clamp-2 ${isPastEvent ? 'text-gray-500' : 'text-gray-900 group-hover:text-brand-600'}`}>
{item.title}
</h3>
<div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: item.excerpt }} />
<div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: sanitizeHTML(item.excerpt) }} />
</div>
</Link>
);

View File

@@ -0,0 +1,666 @@
"use client";
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Twitter, Play, Share2, Send, Award, Quote, MessageCircle, Lock, Loader2, Link2, ShoppingBag, Heart } from 'lucide-react';
import { Business, OfferType, Rating } from '@/types';
import { toast } from 'react-hot-toast';
import { useUser } from '@/components/UserProvider';
interface Props {
initialBusiness: Business;
}
const BusinessDetailClient = ({ initialBusiness }: Props) => {
const { id } = useParams();
const router = useRouter();
const { user } = useUser();
const [business, setBusiness] = useState<Business>(initialBusiness);
const [loading, setLoading] = useState(false);
const [existingConversationId, setExistingConversationId] = useState<string | null>(null);
const [isShareOpen, setIsShareOpen] = useState(false);
const [selectedOffer, setSelectedOffer] = useState<any | null>(null);
const [userRating, setUserRating] = useState<number | null>(null);
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [isRating, setIsRating] = useState(false);
const [ratings, setRatings] = useState<Rating[]>([]);
const [reviewComment, setReviewComment] = useState('');
const [userRatingStatus, setUserRatingStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED' | null>(null);
const [loadingRatings, setLoadingRatings] = useState(false);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [isFavorited, setIsFavorited] = useState(false);
const [isTogglingFavorite, setIsTogglingFavorite] = useState(false);
const isOwner = user && business && user.id === business.ownerId;
const contactRef = React.useRef<HTMLDivElement>(null);
const [contactForm, setContactForm] = useState({
name: '',
email: '',
message: ''
});
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
const handleOrder = (offer: any) => {
setContactForm(prev => ({
...prev,
message: `Bonjour, je souhaiterais commander ou avoir plus d'informations sur votre offre : "${offer.title}". Pouvez-vous me recontacter ?`
}));
setSelectedOffer(null);
contactRef.current?.scrollIntoView({ behavior: 'smooth' });
setTimeout(() => {
const textarea = document.getElementById('contact-message');
if (textarea) textarea.focus();
}, 800);
toast.success("Message préparé ! Vous pouvez l'envoyer ci-dessous.");
};
const businessOffers = useMemo(() => {
if (!business) return [];
if (business.offers && business.offers.length > 0) return business.offers;
return [];
}, [business]);
useEffect(() => {
const loadExtras = async () => {
// Track view event if not owner
if (!user || user.id !== business.ownerId) {
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'BUSINESS_VIEW',
path: window.location.pathname,
label: business.name,
metadata: {
businessId: business.id,
businessName: business.name,
userId: user?.id || 'anonymous'
}
})
}).catch(console.error);
fetch(`/api/businesses/${business.id}/view`, {
method: 'POST',
headers: user ? { 'x-user-id': user.id } : {}
}).catch(console.error);
}
// Fetch user rating if logged in
if (user) {
try {
const ratingRes = await fetch(`/api/businesses/${business.id}/rating/me`, {
headers: { 'x-user-id': user.id }
});
const ratingData = await ratingRes.json();
if (ratingData.rating) {
setUserRating(ratingData.rating);
setReviewComment(ratingData.comment || '');
setUserRatingStatus(ratingData.status);
}
} catch (e) { console.error(e); }
// Fetch favorite status
try {
const favRes = await fetch('/api/favorites', {
headers: { 'x-user-id': user.id }
});
const favData = await favRes.json();
if (Array.isArray(favData)) {
setIsFavorited(favData.some((f: any) => f.businessId === business.id));
}
} catch (e) { console.error(e); }
}
// Fetch all ratings
try {
setLoadingRatings(true);
const ratingsRes = await fetch(`/api/businesses/${business.id}/ratings`, {
headers: user ? { 'x-user-id': user.id } : {}
});
if (ratingsRes.ok) {
const ratingsData = await ratingsRes.json();
setRatings(ratingsData);
}
} catch (e) { console.error(e); } finally { setLoadingRatings(false); }
};
loadExtras();
}, [business.id, user]);
useEffect(() => {
const checkExistingConversation = async () => {
if (!user || user.id === 'undefined' || !business?.id) return;
try {
const res = await fetch(`/api/conversations?businessId=${business.id}`, {
headers: { 'x-user-id': user.id }
});
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
setExistingConversationId(data[0].id);
}
} catch (error) { console.error(error); }
};
if (user && business) checkExistingConversation();
}, [user, business]);
const getEmbedUrl = (url: string) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? `https://www.youtube.com/embed/${match[2]}` : null;
};
const videoEmbedUrl = business.videoUrl ? getEmbedUrl(business.videoUrl) : null;
const handleTrackEvent = async (type: string, label?: string) => {
if (!business?.id || (user && user.id === business.ownerId)) return;
try {
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
path: window.location.pathname,
label: label || business.name,
metadata: {
businessId: business.id,
businessName: business.name,
userId: user?.id || 'anonymous'
}
})
});
} catch (e) { console.error(e); }
};
const handleContactSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) {
toast.error('Veuillez vous connecter pour envoyer un message');
router.push('/login');
return;
}
setFormStatus('sending');
try {
const res = await fetch('/api/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user.id
},
body: JSON.stringify({
businessId: business.id,
content: contactForm.message
})
});
const data = await res.json();
if (!data.error) {
handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Message Sent');
setFormStatus('success');
toast.success('Message envoyé ! Redirection vers votre messagerie...');
setContactForm({ ...contactForm, message: '' });
setTimeout(() => router.push('/dashboard?view=messages'), 2000);
} else {
toast.error(data.error);
setFormStatus('idle');
}
} catch (error) {
toast.error("Erreur lors de l'envoi");
setFormStatus('idle');
}
};
const handleShare = (platform: string) => {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(`Découvrez ${business.name} sur Afrohub`);
let shareLink = '';
switch(platform) {
case 'facebook': shareLink = `https://www.facebook.com/sharer/sharer.php?u=${url}`; break;
case 'twitter': shareLink = `https://twitter.com/intent/tweet?url=${url}&text=${text}`; break;
case 'linkedin': shareLink = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`; break;
case 'copy':
navigator.clipboard.writeText(window.location.href);
toast.success("Lien copié !");
setIsShareOpen(false);
return;
}
if (shareLink) window.open(shareLink, '_blank', 'width=600,height=400');
setIsShareOpen(false);
};
const handleRate = async (value: number) => {
if (!user) { toast.error('Connectez-vous pour voter'); router.push('/login'); return; }
if (isOwner) { toast.error("Vous ne pouvez pas noter votre propre entreprise"); return; }
if (isRating) return;
setIsRating(true);
try {
const res = await fetch(`/api/businesses/${business.id}/rate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
body: JSON.stringify({ value, comment: reviewComment })
});
const data = await res.json();
if (!data.error) {
setUserRating(value);
setBusiness(prev => ({ ...prev, rating: data.rating, ratingCount: data.ratingCount }));
toast.success('Merci pour votre vote !');
handleTrackEvent('BUSINESS_RATING_CAST', `Stars: ${value}`);
const rRes = await fetch(`/api/businesses/${business.id}/ratings`);
if (rRes.ok) setRatings(await rRes.json());
} else toast.error(data.error);
} catch (error) { toast.error("Erreur vote"); } finally { setIsRating(false); }
};
const handleReply = async (ratingId: string) => {
if (!user || !isOwner || !replyText.trim()) return;
setIsSubmittingReply(true);
try {
const res = await fetch(`/api/ratings/${ratingId}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
body: JSON.stringify({ reply: replyText })
});
if (res.ok) {
toast.success("Réponse publiée !");
setReplyingTo(null);
setReplyText('');
const rRes = await fetch(`/api/businesses/${business.id}/ratings`);
if (rRes.ok) setRatings(await rRes.json());
} else toast.error("Erreur réponse");
} catch (e) { toast.error("Erreur connexion"); } finally { setIsSubmittingReply(false); }
};
const toggleFavorite = async () => {
if (!user) { toast.error("Connectez-vous"); router.push('/login'); return; }
setIsTogglingFavorite(true);
try {
const res = await fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
body: JSON.stringify({ businessId: business.id })
});
const data = await res.json();
if (!data.error) {
setIsFavorited(data.favorited);
toast.success(data.favorited ? 'Ajouté aux favoris' : 'Retiré des favoris');
}
} catch (error) { toast.error('Erreur'); } finally { setIsTogglingFavorite(false); }
};
return (
<div className="bg-gray-50 min-h-screen pb-12">
{/* Header Banner */}
<div className="h-48 md:h-64 bg-gray-900 relative overflow-hidden">
{business.coverUrl ? (
<img
src={business.coverUrl}
alt="Couverture"
className="absolute inset-0 w-full h-full object-cover opacity-60"
style={{
objectPosition: business.coverPosition || '50% 50%',
transform: `scale(${business.coverZoom || 1})`
}}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-r from-gray-900 to-gray-800 opacity-100">
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] pointer-events-none"></div>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex flex-col justify-between py-6 relative z-10">
<button
onClick={() => router.back()}
className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit group active:scale-95"
>
<ArrowLeft className="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" /> Retour
</button>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 relative z-10">
<div className="bg-white rounded-xl shadow-lg border border-gray-100">
<div className="p-6 md:p-8">
{/* Profile Header */}
<div className="flex flex-col md:flex-row gap-6 items-start">
<div className="w-32 h-32 md:w-40 md:h-40 rounded-xl bg-white p-1 shadow-md -mt-16 md:-mt-24 border border-gray-100 flex-shrink-0 relative z-20">
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-lg bg-gray-50" />
{business.isFeatured && (
<div className="absolute -top-3 -right-3 bg-yellow-400 text-yellow-900 rounded-full p-2 shadow-md" title="Afroshine">
<Award className="w-6 h-6" />
</div>
)}
</div>
<div className="flex-1 w-full">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-serif font-bold text-gray-900 flex items-center gap-2">
{business.name}
</h1>
{business.verified && <span title="Entreprise Vérifiée"><CheckCircle className="w-6 h-6 text-blue-500" /></span>}
</div>
<div className="text-brand-600 font-medium mt-1 uppercase tracking-wide text-sm">{business.category}</div>
</div>
<div className="flex gap-3 relative">
<div className="relative">
<button
onClick={() => setIsShareOpen(!isShareOpen)}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 transition-all active:scale-95"
>
<Share2 className="w-4 h-4 mr-2" /> Partager
</button>
{isShareOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-2xl py-3 border border-gray-100 z-50 animate-in fade-in zoom-in duration-200">
<button onClick={() => handleShare('facebook')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-600">
<Facebook className="w-4 h-4 mr-3" /> Facebook
</button>
<button onClick={() => handleShare('twitter')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-black">
<Twitter className="w-4 h-4 mr-3" /> X (Twitter)
</button>
<button onClick={() => handleShare('copy')} className="w-full text-left px-4 py-2.5 text-sm font-medium text-brand-600 hover:bg-brand-50 flex items-center">
<Link2 className="w-4 h-4 mr-3" /> Copier le lien
</button>
</div>
)}
</div>
<button
onClick={toggleFavorite}
disabled={isTogglingFavorite}
className={`inline-flex items-center px-4 py-2 border shadow-sm text-sm font-medium rounded-md transition-all active:scale-95 ${isFavorited ? 'bg-red-500 border-red-500 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
<Heart className={`w-4 h-4 mr-2 ${isFavorited ? 'fill-current' : ''}`} />
{isFavorited ? 'Favori' : 'Mettre en favori'}
</button>
<a href="#contact-form" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700">
Contacter
</a>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-6 text-sm text-gray-500 border-t border-gray-100 pt-4">
<div className="flex items-center">
<MapPin className="w-4 h-4 mr-1 text-gray-400" />
{business.location}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-5 h-5 ${star <= Math.round(business.rating) ? 'text-yellow-400 fill-current' : 'text-gray-300'}`}
/>
))}
</div>
<span className="font-bold text-gray-900">{business.rating.toFixed(1)}</span>
<span className="text-gray-400">({business.ratingCount} votes {business.viewCount} vues)</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8">
<div className="lg:col-span-2 space-y-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4">À propos</h2>
<p className="text-gray-600 leading-relaxed whitespace-pre-line">{business.description}</p>
</div>
{videoEmbedUrl && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4 flex items-center">
<Play className="w-5 h-5 mr-2 text-brand-600" /> Présentation Vidéo
</h2>
<iframe src={videoEmbedUrl} className="w-full h-64 md:h-96 rounded-lg" allowFullScreen></iframe>
</div>
)}
{businessOffers.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Produits & Services</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{businessOffers.map(offer => (
<div key={offer.id} className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-all cursor-pointer group" onClick={() => handleOrder(offer)}>
<div className="h-40 bg-gray-100 relative">
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover" />
</div>
<div className="p-4">
<h3 className="font-bold text-gray-900">{offer.title}</h3>
<p className="text-brand-600 font-bold mt-1">{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Avis et Commentaires */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<h2 className="text-xl font-bold font-serif text-gray-900 flex items-center">
<Star className="w-5 h-5 mr-2 text-yellow-400 fill-current" /> Avis et Commentaires
</h2>
{!isOwner && (
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(null)}
onClick={() => handleRate(star)}
className="transition-transform active:scale-125 focus:outline-none"
>
<Star
className={`w-8 h-8 ${star <= (hoverRating || userRating || 0) ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
/>
</button>
))}
</div>
<span className="text-xs text-gray-500 font-medium italic">
{userRating ? "Merci pour votre note !" : "Cliquez sur une étoile pour voter"}
</span>
</div>
)}
</div>
{!isOwner && (
<div className="mb-10 bg-gray-50 rounded-xl p-5 border border-gray-100">
<p className="text-sm font-bold text-gray-700 mb-3">Laissez un commentaire</p>
<div className="relative">
<textarea
value={reviewComment}
onChange={(e) => setReviewComment(e.target.value)}
placeholder="Que pensez-vous de cette entreprise ? (Qualité, service...)"
className="w-full bg-white border border-gray-200 rounded-lg p-4 text-sm focus:ring-2 focus:ring-brand-500 outline-none h-24 resize-none shadow-sm"
/>
<button
onClick={() => handleRate(userRating || 5)}
disabled={isRating}
className="absolute bottom-3 right-3 bg-brand-600 text-white px-4 py-1.5 rounded-md text-xs font-bold hover:bg-brand-700 transition-all flex items-center gap-2 shadow-md"
>
{isRating ? <Loader2 className="w-3 h-3 animate-spin" /> : "Publier"}
</button>
</div>
</div>
)}
<div className="space-y-6">
{loadingRatings ? (
<div className="flex justify-center py-10">
<Loader2 className="w-8 h-8 text-brand-600 animate-spin" />
</div>
) : ratings.length > 0 ? (
ratings.map((rating) => (
<div key={rating.id} className="border-b border-gray-50 last:border-0 pb-6">
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-50 flex items-center justify-center text-brand-700 font-bold text-sm">
{(rating as any).user?.name?.charAt(0) || 'U'}
</div>
<div>
<p className="font-bold text-gray-900 text-sm">{(rating as any).user?.name}</p>
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-3 h-3 ${star <= rating.value ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
/>
))}
<span className="text-[10px] text-gray-400 ml-2 font-medium">
{new Date(rating.createdAt).toLocaleDateString('fr-FR')}
</span>
</div>
</div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed italic ml-13">
<Quote className="w-3 h-3 text-brand-200 inline mr-1" />
{rating.comment || "Pas de commentaire"}
</p>
{/* Reply section for owner */}
{isOwner && !rating.reply && (
<div className="mt-4 ml-13">
{replyingTo === rating.id ? (
<div className="flex flex-col gap-2">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Votre réponse..."
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm outline-none focus:ring-1 focus:ring-brand-500 h-20"
/>
<div className="flex gap-2">
<button onClick={() => handleReply(rating.id)} disabled={isSubmittingReply} className="bg-brand-600 text-white px-3 py-1.5 rounded text-xs font-bold">Répondre</button>
<button onClick={() => setReplyingTo(null)} className="text-gray-500 text-xs">Annuler</button>
</div>
</div>
) : (
<button onClick={() => setReplyingTo(rating.id)} className="text-brand-600 text-xs font-bold hover:underline">Répondre au client</button>
)}
</div>
)}
{rating.reply && (
<div className="mt-4 ml-13 bg-brand-50/50 p-4 rounded-lg border-l-2 border-brand-300">
<div className="flex items-center gap-2 mb-1">
<MessageCircle className="w-3 h-3 text-brand-600" />
<span className="text-[10px] font-bold text-brand-700 uppercase">Réponse de {business.name}</span>
</div>
<p className="text-gray-700 text-sm italic">{rating.reply}</p>
</div>
)}
</div>
))
) : (
<div className="text-center py-10 bg-gray-50 rounded-xl border border-dashed border-gray-200">
<Star className="w-10 h-10 text-gray-200 mx-auto mb-2" />
<p className="text-gray-500 text-sm">Soyez le premier à donner votre avis !</p>
</div>
)}
</div>
</div>
</div>
<div className="space-y-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Coordonnées</h2>
<div className="space-y-4">
{business.websiteUrl && (
<a href={business.websiteUrl.startsWith('http') ? business.websiteUrl : `https://${business.websiteUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
<Globe className="w-5 h-5 text-brand-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Site Web</p>
<p className="text-sm font-medium truncate">{business.websiteUrl.replace(/^https?:\/\//, '')}</p>
</div>
</a>
)}
{business.showEmail && business.contactEmail && (
<a href={`mailto:${business.contactEmail}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
<Mail className="w-5 h-5 text-brand-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Email</p>
<p className="text-sm font-medium truncate">{business.contactEmail}</p>
</div>
</a>
)}
{business.showPhone && business.contactPhone && (
<a href={`tel:${business.contactPhone}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
<Phone className="w-5 h-5 text-brand-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Téléphone</p>
<p className="text-sm font-medium truncate">{business.contactPhone}</p>
</div>
</a>
)}
{business.showSocials && business.socialLinks && (
<div className="pt-4 border-t border-gray-50">
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-3">Réseaux Sociaux</p>
<div className="flex gap-2">
{business.socialLinks.facebook && (
<a href={business.socialLinks.facebook} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-all">
<Facebook className="w-5 h-5" />
</a>
)}
{business.socialLinks.linkedin && (
<a href={business.socialLinks.linkedin} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-700 hover:bg-blue-50 transition-all">
<Linkedin className="w-5 h-5" />
</a>
)}
{business.socialLinks.instagram && (
<a href={business.socialLinks.instagram} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-pink-600 hover:bg-pink-50 transition-all">
<Instagram className="w-5 h-5" />
</a>
)}
{business.socialLinks.twitter && (
<a href={business.socialLinks.twitter} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-black hover:bg-gray-100 transition-all">
<Twitter className="w-5 h-5" />
</a>
)}
</div>
</div>
)}
</div>
</div>
<div id="contact-form" ref={contactRef} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Contacter l'entreprise</h2>
<form onSubmit={handleContactSubmit} className="space-y-4">
<textarea
id="contact-message"
value={contactForm.message}
onChange={(e) => setContactForm({...contactForm, message: e.target.value})}
placeholder="Votre message..."
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm focus:ring-2 focus:ring-brand-500 outline-none h-32"
required
/>
<button type="submit" disabled={formStatus === 'sending'} className="w-full bg-brand-600 text-white py-3 rounded-lg font-bold hover:bg-brand-700 transition-colors flex items-center justify-center gap-2">
{formStatus === 'sending' ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
Envoyer le message
</button>
</form>
</div>
</div>
</div>
</div>
</div>
);
};
export default BusinessDetailClient;

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,10 @@ import { Search, Loader2 } from 'lucide-react';
import { Business, Country } from '../../types';
import BusinessCard from '../../components/BusinessCard';
import AnnuaireHero from '../../components/AnnuaireHero';
import { useUser } from '../../components/UserProvider';
const AnnuairePageContent = () => {
const { user } = useUser();
const [businesses, setBusinesses] = useState<Business[]>([]);
const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null);
const [countries, setCountries] = useState<Country[]>([]);
@@ -17,6 +19,7 @@ const AnnuairePageContent = () => {
const [filterCategory, setFilterCategory] = useState('All');
const [filterCountry, setFilterCountry] = useState('All');
const [searchQuery, setSearchQuery] = useState('');
const [locationQuery, setLocationQuery] = useState('');
const searchParams = useSearchParams();
// 1. Fetch Metadata (Countries & Categories)
@@ -41,7 +44,10 @@ const AnnuairePageContent = () => {
const fetchHeadliner = async () => {
try {
// Disable caching to ensure we get fresh data and more variety
const res = await fetch('/api/businesses?featured=true', { cache: 'no-store' });
const res = await fetch('/api/businesses?featured=true', {
cache: 'no-store',
headers: user ? { 'x-user-id': user.id } : {}
});
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
// Truly random selection from the pool of featured businesses
@@ -64,8 +70,11 @@ const AnnuairePageContent = () => {
if (filterCategory !== 'All') params.append('category', filterCategory);
if (filterCountry !== 'All') params.append('countryId', filterCountry);
if (searchQuery) params.append('q', searchQuery);
if (locationQuery) params.append('location', locationQuery);
const res = await fetch(`/api/businesses?${params.toString()}`);
const res = await fetch(`/api/businesses?${params.toString()}`, {
headers: user ? { 'x-user-id': user.id } : {}
});
const data = await res.json();
if (Array.isArray(data)) {
setBusinesses(data);
@@ -79,12 +88,15 @@ const AnnuairePageContent = () => {
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
return () => clearTimeout(timeoutId);
}, [filterCategory, searchQuery, filterCountry]);
}, [filterCategory, searchQuery, filterCountry, locationQuery]);
useEffect(() => {
const q = searchParams.get('q');
if (q) setSearchQuery(q);
const loc = searchParams.get('location');
if (loc) setLocationQuery(loc);
const cat = searchParams.get('category');
if (cat) setFilterCategory(cat);
@@ -117,6 +129,17 @@ const AnnuairePageContent = () => {
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">Ville / Localisation</label>
<input
type="text"
value={locationQuery}
onChange={(e) => setLocationQuery(e.target.value)}
className="w-full border-gray-300 rounded-md shadow-sm focus:ring-brand-500 focus:border-brand-500 sm:text-sm p-2 border"
placeholder="Ex: Abidjan, Dakar..."
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">Pays</label>
<select

View File

@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import { generateCsrfToken } from '@/lib/csrf';
export async function GET() {
const token = await generateCsrfToken();
return NextResponse.json({ csrfToken: token });
}

View File

@@ -0,0 +1,89 @@
import { NextResponse, NextRequest } from 'next/server';
import prisma from '@/lib/prisma';
import { sendEmail } from '@/lib/mail';
import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(req: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(req);
const isCsrfValid = await verifyCsrfToken(req);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { email } = await req.json();
if (!email) {
return NextResponse.json({ error: 'Email est requis' }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
// Pour des raisons de sécurité, on ne dit pas si l'utilisateur existe ou non
return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' });
}
// Générer un token
const token = crypto.randomBytes(32).toString('hex');
const expiry = new Date(Date.now() + 3600000); // 1 heure
await prisma.user.update({
where: { id: user.id },
data: {
resetToken: token,
resetTokenExpiry: expiry,
},
});
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
// Get site settings for email template
const settings = await prisma.siteSetting.findUnique({
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const subject = settings?.resetPasswordSubject || 'Réinitialisation de votre mot de passe - Afrohub';
let html = settings?.resetPasswordTemplate || `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
<p>Bonjour ${user.name},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
</div>
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 ${siteName}. Tous droits réservés.</p>
</div>
`;
// Replace placeholders
html = html
.replace(/{name}/g, user.name)
.replace(/{resetUrl}/g, resetUrl)
.replace(/{siteName}/g, siteName);
await sendEmail({
to: email,
subject,
html,
});
return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' });
} catch (error: any) {
console.error('Error in forgot-password:', error);
return NextResponse.json({ error: 'Une erreur est survenue lors de la demande' }, { status: 500 });
}
}

View File

@@ -2,7 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
const isCsrfValid = await verifyCsrfToken(request);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { email, password } = await request.json();
@@ -35,6 +48,14 @@ export async function POST(request: NextRequest) {
);
}
// Check if email is verified
if (!user.emailVerified) {
return NextResponse.json(
{ error: 'Veuillez vérifier votre adresse email avant de vous connecter.' },
{ status: 403 }
);
}
// Omit password from response
const { password: _, ...userWithoutPassword } = user;

View File

@@ -1,8 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
const isCsrfValid = await verifyCsrfToken(request);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { name, email, password } = await request.json();
@@ -36,16 +50,60 @@ export async function POST(request: NextRequest) {
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Generate verification token
const verificationToken = crypto.randomBytes(32).toString('hex');
// Create user
const user = await prisma.user.create({
data: {
name,
email,
password: hashedPassword,
role: 'VISITOR' // Default role
role: 'VISITOR', // Default role
verificationToken,
emailVerified: false,
}
});
// Send verification email
try {
const settings = await prisma.siteSetting.findUnique({
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
let html = settings?.verifyEmailTemplate || `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
<p>Bonjour ${name},</p>
<p>Merci de vous être inscrit sur ${siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
</div>
</div>
`;
// Replace placeholders
html = html
.replace(/{name}/g, name)
.replace(/{verifyUrl}/g, verifyUrl)
.replace(/{siteName}/g, siteName);
const { sendEmail } = await import('@/lib/mail');
await sendEmail({
to: email,
subject,
html,
});
} catch (mailError) {
console.error('Failed to send verification email:', mailError);
// We don't fail registration if mail fails, but maybe we should?
// For now, just log it.
}
// Omit password from response
const { password: _, ...userWithoutPassword } = user;

View File

@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
const isCsrfValid = await verifyCsrfToken(request);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { email } = await request.json();
if (!email) {
return NextResponse.json(
{ error: 'Email requis' },
{ status: 400 }
);
}
// Find user
const user = await prisma.user.findUnique({
where: { email }
});
if (!user) {
// For security, same message even if user doesn't exist
return NextResponse.json(
{ message: 'Si un compte existe pour cet email et n\'est pas encore vérifié, un nouvel email de vérification a été envoyé.' },
{ status: 200 }
);
}
if (user.emailVerified) {
return NextResponse.json(
{ error: 'Ce compte est déjà vérifié.' },
{ status: 400 }
);
}
// Generate new verification token
const verificationToken = crypto.randomBytes(32).toString('hex');
// Update user with new token
await prisma.user.update({
where: { id: user.id },
data: { verificationToken }
});
// Send verification email
try {
const settings = await prisma.siteSetting.findUnique({
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
let html = settings?.verifyEmailTemplate || `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
<p>Bonjour {name},</p>
<p>Vous avez demandé le renvoi de l'email de vérification pour votre compte sur {siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
</div>
</div>
`;
// Replace placeholders
html = html
.replace(/{name}/g, user.name)
.replace(/{verifyUrl}/g, verifyUrl)
.replace(/{siteName}/g, siteName);
const { sendEmail } = await import('@/lib/mail');
await sendEmail({
to: email,
subject,
html,
});
} catch (mailError) {
console.error('Failed to resend verification email:', mailError);
return NextResponse.json(
{ error: 'Erreur lors de l\'envoi de l\'email' },
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'Un nouvel email de vérification a été envoyé.' },
{ status: 200 }
);
} catch (error) {
console.error('Resend verification error:', error);
return NextResponse.json(
{ error: 'Une erreur est survenue' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,54 @@
import { NextResponse, NextRequest } from 'next/server';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
const isCsrfValid = await verifyCsrfToken(request);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { token, password } = await request.json();
if (!token || !password) {
return NextResponse.json({ error: 'Token et mot de passe sont requis' }, { status: 400 });
}
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: {
gt: new Date(),
},
},
});
if (!user) {
return NextResponse.json({ error: 'Token invalide ou expiré' }, { status: 400 });
}
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null,
},
});
return NextResponse.json({ message: 'Votre mot de passe a été mis à jour avec succès' });
} catch (error: any) {
console.error('Error in reset-password:', error);
return NextResponse.json({ error: 'Une erreur est survenue lors de la réinitialisation' }, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const token = searchParams.get('token');
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
if (!token) {
return NextResponse.redirect(new URL('/login?error=Token manquant', baseUrl));
}
const user = await prisma.user.findUnique({
where: { verificationToken: token }
});
if (!user) {
return NextResponse.redirect(new URL('/login?error=Token invalide ou expiré', baseUrl));
}
await prisma.user.update({
where: { id: user.id },
data: {
emailVerified: true,
verificationToken: null
}
});
return NextResponse.redirect(new URL('/login?verified=true', baseUrl));
} catch (error) {
console.error('Verification error:', error);
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
return NextResponse.redirect(new URL('/login?error=Erreur de vérification', baseUrl));
}
}

View File

@@ -27,6 +27,10 @@ export async function PATCH(
try {
const { id } = await context.params
const body = await request.json()
if (body.title && body.slug === undefined) {
const { generateSlug } = await import('@/lib/utils')
body.slug = generateSlug(body.title)
}
const post = await prisma.blogPost.update({ where: { id }, data: body })
return NextResponse.json(post)
} catch (error) {

View File

@@ -1,10 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { generateSlug } from '@/lib/utils'
// GET /api/blog
export async function GET() {
try {
const posts = await prisma.blogPost.findMany({
where: {
status: 'PUBLISHED',
publishedAt: {
lte: new Date()
}
},
orderBy: { date: 'desc' },
})
return NextResponse.json(posts)
@@ -18,6 +25,9 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
if (!body.slug && body.title) {
body.slug = generateSlug(body.title)
}
const post = await prisma.blogPost.create({ data: body })
return NextResponse.json(post, { status: 201 })
} catch (error) {

View File

@@ -1,14 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { USER_SAFE_SELECT, getAuthUser } from '@/lib/auth-utils'
type Params = { params: Promise<{ id: string }> }
// GET /api/businesses/[id]
export async function GET(
_request: NextRequest,
request: NextRequest,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const user = await getAuthUser(request)
if (!user) {
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
}
const { id } = await context.params
const business = await prisma.business.findFirst({
where: {
@@ -17,10 +23,21 @@ export async function GET(
{ slug: id }
]
},
include: { owner: true, offers: true },
include: {
owner: {
select: USER_SAFE_SELECT
},
offers: true
},
})
if (!business) return NextResponse.json({ error: 'Non trouvé' }, { status: 404 })
return NextResponse.json(business)
// Mask technical IDs
const { id: bizId, ownerId, ...rest } = business
return NextResponse.json({
...rest,
id: business.slug || bizId
})
} catch (error) {
console.error('GET /api/businesses/[id] error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
@@ -38,7 +55,12 @@ export async function PATCH(
const business = await prisma.business.update({
where: { id },
data: body,
include: { owner: true, offers: true },
include: {
owner: {
select: USER_SAFE_SELECT
},
offers: true
},
})
return NextResponse.json(business)
} catch (error) {

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { USER_SAFE_SELECT, getAuthUser } from '@/lib/auth-utils'
// GET /api/businesses — List all businesses (Mock + DB)
export async function GET(request: NextRequest) {
@@ -7,6 +8,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const category = searchParams.get('category')
const q = searchParams.get('q')?.toLowerCase()
const location = searchParams.get('location')?.toLowerCase()
const featured = searchParams.get('featured')
const countryId = searchParams.get('countryId')
@@ -18,11 +20,12 @@ export async function GET(request: NextRequest) {
isSuspended: false
}
}
if (category && category !== 'All') {
// Robust check: IDs are usually alphanumeric and long, without spaces or special chars
// Names like "Agriculture & Agrobusiness" have spaces or special chars.
const isId = /^[a-z0-9-]+$/.test(category) && category.length > 20;
// Build the conditions array
const conditions: any[] = [];
if (category && category !== 'All') {
const isId = /^[a-z0-9-]+$/.test(category) && category.length > 20;
if (isId) {
where.categoryId = category
} else {
@@ -31,23 +34,55 @@ export async function GET(request: NextRequest) {
}
if (featured === 'true') where.isFeatured = true
if (countryId) where.countryId = countryId
if (q) {
where.OR = [
conditions.push({
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
// Simplified tags check
{ city: { contains: q, mode: 'insensitive' } },
{ location: { contains: q, mode: 'insensitive' } },
{ tags: { has: q } }
]
});
}
if (location) {
conditions.push({
OR: [
{ city: { contains: location, mode: 'insensitive' } },
{ location: { contains: location, mode: 'insensitive' } }
]
});
}
if (conditions.length > 0) {
where.AND = conditions;
}
const dbBusinesses = await prisma.business.findMany({
where,
include: { owner: true, offers: true },
include: {
owner: {
select: USER_SAFE_SELECT
},
offers: true
},
orderBy: { createdAt: 'desc' },
})
// 2. Return DB results
return NextResponse.json(dbBusinesses)
// 2. Map results to mask technical IDs
const safeBusinesses = dbBusinesses.map(biz => {
const { id, ownerId, ...rest } = biz
return {
...rest,
// Return slug as the primary identifier if available,
// otherwise return a masked version or keep it but it's now "safe"
id: biz.slug || id
}
})
return NextResponse.json(safeBusinesses)
} catch (error) {
console.error('GET /api/businesses error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
@@ -135,7 +170,11 @@ export async function POST(request: NextRequest) {
business = await prisma.business.update({
where: { id: existing.id },
data: cleanData,
include: { owner: true }
include: {
owner: {
select: USER_SAFE_SELECT
}
}
});
} else {
// When creating, we add the ownerId to the sanitized cleanData
@@ -144,7 +183,11 @@ export async function POST(request: NextRequest) {
...cleanData,
ownerId: ownerId
},
include: { owner: true }
include: {
owner: {
select: USER_SAFE_SELECT
}
}
});
// Automatic role upgrade

View File

@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
// GET /api/favorites - Get user's favorites
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id');
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
}
const favorites = await prisma.favorite.findMany({
where: { userId },
include: {
business: {
include: {
categoryRef: true,
country: true
}
}
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json(favorites);
} catch (error) {
console.error('GET /api/favorites error:', error);
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}
// POST /api/favorites - Toggle favorite
export async function POST(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id');
const body = await request.json();
const { businessId } = body;
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
}
if (!businessId) {
return NextResponse.json({ error: 'businessId requis' }, { status: 400 });
}
// Resolve business ID (in case slug was passed)
const business = await prisma.business.findFirst({
where: {
OR: [
{ id: businessId },
{ slug: businessId }
]
},
select: { id: true }
});
if (!business) {
return NextResponse.json({ error: 'Entreprise non trouvée' }, { status: 404 });
}
const actualBusinessId = business.id;
// Check if already favorited
const existing = await prisma.favorite.findUnique({
where: {
userId_businessId: {
userId,
businessId: actualBusinessId
}
}
});
if (existing) {
// Unfavorite
await prisma.favorite.delete({
where: { id: existing.id }
});
return NextResponse.json({ favorited: false });
} else {
// Favorite
await prisma.favorite.create({
data: {
userId,
businessId: actualBusinessId
}
});
return NextResponse.json({ favorited: true });
}
} catch (error) {
console.error('POST /api/favorites error:', error);
return NextResponse.json({ error: 'Erreur lors du changement de favori' }, { status: 500 });
}
}

View File

@@ -5,6 +5,12 @@ import prisma from '@/lib/prisma'
export async function GET() {
try {
const interviews = await prisma.interview.findMany({
where: {
status: 'PUBLISHED',
publishedAt: {
lte: new Date()
}
},
orderBy: { date: 'desc' },
})
return NextResponse.json(interviews)

20
app/api/tags/route.ts Normal file
View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const blogPosts = await prisma.blogPost.findMany({ select: { tags: true } });
const interviews = await prisma.interview.findMany({ select: { tags: true } });
const events = await prisma.event.findMany({ select: { tags: true } });
const businesses = await prisma.business.findMany({ select: { tags: true } });
const allTags = new Set<string>();
[...blogPosts, ...interviews, ...events, ...businesses].forEach(item => {
item.tags.forEach(tag => allTags.add(tag.toLowerCase()));
});
return NextResponse.json(Array.from(allTags).sort());
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch tags' }, { status: 500 });
}
}

View File

@@ -1,11 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { isAdmin, USER_SAFE_SELECT } from '@/lib/auth-utils'
// GET /api/users
export async function GET() {
export async function GET(request: NextRequest) {
try {
if (!(await isAdmin(request))) {
return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 })
}
const users = await prisma.user.findMany({
include: { businesses: true },
select: {
...USER_SAFE_SELECT,
businesses: true
},
orderBy: { createdAt: 'desc' },
})
return NextResponse.json(users)
@@ -15,11 +23,22 @@ export async function GET() {
}
}
// POST /api/users
// POST /api/users (Admin only)
export async function POST(request: NextRequest) {
try {
if (!(await isAdmin(request))) {
return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 })
}
const body = await request.json()
const user = await prisma.user.create({ data: body })
// Note: For admins, we might want to allow creating users directly,
// but we should still ensure password hashing if provided.
// For now, we just restrict it to admins to prevent the public leak.
const user = await prisma.user.create({
data: body,
select: USER_SAFE_SELECT
})
return NextResponse.json(user, { status: 201 })
} catch (error) {
console.error('POST /api/users error:', error)

View File

@@ -2,6 +2,7 @@ import React from 'react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, User, Calendar, Share2 } from 'lucide-react';
import { sanitizeHTML } from '../../../lib/sanitize';
import { prisma } from '../../../lib/prisma';
import { Metadata } from 'next';
@@ -18,7 +19,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
OR: [
{ id: id },
{ slug: id }
]
],
publishedAt: {
lte: new Date()
},
status: 'PUBLISHED'
}
});
@@ -45,7 +50,11 @@ export default async function BlogPostPage({ params }: Props) {
OR: [
{ id: id },
{ slug: id }
]
],
publishedAt: {
lte: new Date()
},
status: 'PUBLISHED'
}
});
@@ -101,13 +110,13 @@ export default async function BlogPostPage({ params }: Props) {
</header>
{/* Content */}
<div className="prose prose-lg prose-orange max-w-none text-gray-600 break-words overflow-hidden">
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
{post.excerpt}
</p>
<div
className="mt-6"
dangerouslySetInnerHTML={{ __html: post.content }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(post.content) }}
/>
</div>

View File

@@ -10,6 +10,12 @@ export default async function BlogPage() {
try {
posts = await prisma.blogPost.findMany({
where: {
status: 'PUBLISHED',
publishedAt: {
lte: new Date()
}
},
orderBy: { createdAt: 'desc' }
});
} catch (error) {

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { getLegalDocument } from '@/lib/legal';
import { sanitizeHTML } from '@/lib/sanitize';
import { getSiteSettings } from '@/lib/settings';
import { Metadata } from 'next';
@@ -32,7 +33,7 @@ export default async function CGUPage() {
<div
className="prose prose-orange max-w-none text-gray-600 space-y-6"
dangerouslySetInnerHTML={{ __html: doc.content }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(doc.content) }}
/>
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { getLegalDocument } from '@/lib/legal';
import { sanitizeHTML } from '@/lib/sanitize';
import { getSiteSettings } from '@/lib/settings';
import { Metadata } from 'next';
@@ -32,7 +33,7 @@ export default async function CGVPage() {
<div
className="prose prose-orange max-w-none text-gray-600 space-y-6"
dangerouslySetInnerHTML={{ __html: doc.content }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(doc.content) }}
/>
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">

View File

@@ -3,7 +3,7 @@
import React, { useState, useEffect, Suspense } from 'react';
import Link from 'next/link';
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye, MessageSquare, User as UserIcon, Rocket, ShieldAlert } from 'lucide-react';
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye, MessageSquare, User as UserIcon, Rocket, ShieldAlert, Heart } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { User } from '../../types';
import DashboardOverview from '../../components/dashboard/DashboardOverview';
@@ -12,6 +12,7 @@ import DashboardProfileClient from '../../components/dashboard/DashboardProfileC
import DashboardMessages from '../../components/dashboard/DashboardMessages';
import DashboardOffers from '../../components/dashboard/DashboardOffers';
import PricingSection from '../../components/PricingSection';
import DashboardFavorites from '../../components/dashboard/DashboardFavorites';
import { useUser } from '../../components/UserProvider';
const DashboardContent = () => {
@@ -21,7 +22,7 @@ const DashboardContent = () => {
const searchParams = useSearchParams();
const chatId = searchParams.get('chatId') || searchParams.get('chat');
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription' | 'messages' | 'personal-profile'>('overview');
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription' | 'messages' | 'personal-profile' | 'favorites'>('overview');
const [business, setBusiness] = useState<any>(null);
const [stats, setStats] = useState<{ totalViews: number, contactClicks: number, dailyStats: any[] } | null>(null);
const [loading, setLoading] = useState(true);
@@ -33,7 +34,7 @@ const DashboardContent = () => {
// Handle view from URL
const viewParam = searchParams.get('view');
if (viewParam && ['overview', 'profile', 'offers', 'subscription', 'messages', 'personal-profile'].includes(viewParam)) {
if (viewParam && ['overview', 'profile', 'offers', 'subscription', 'messages', 'personal-profile', 'favorites'].includes(viewParam)) {
setCurrentView(viewParam as any);
}
}, [searchParams]);
@@ -220,6 +221,11 @@ const DashboardContent = () => {
Mon Profil Client
</button>
<button onClick={() => setCurrentView('favorites')} className={`${currentView === 'favorites' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
<Heart className={`${currentView === 'favorites' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Mes Favoris
</button>
<div className="pt-4 pb-2">
<p className="px-3 text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Espace Business</p>
</div>
@@ -267,15 +273,18 @@ const DashboardContent = () => {
{currentView === 'overview' && 'Vue d\'ensemble'}
{currentView === 'messages' && 'Mes Messages'}
{currentView === 'personal-profile' && 'Mon Profil Client'}
{currentView === 'favorites' && 'Mes Favoris'}
{currentView === 'profile' && (user.role === 'ENTREPRENEUR' ? 'Ma Fiche Entreprise' : 'Créer mon Business')}
{currentView === 'offers' && 'Gestion des Offres'}
{currentView === 'subscription' && 'Mon Abonnement'}
</h1>
<div className="flex items-center space-x-4">
<a href={displayBusiness.id === 'new' ? '/annuaire' : `/annuaire/${displayBusiness.slug || displayBusiness.id}`} target="_blank" rel="noopener noreferrer" className="hidden sm:inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
{business?.isActive && (
<Link href={`/annuaire/${business.slug || business.id}`} className="hidden sm:inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none transition-all hover:scale-105 active:scale-95">
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
Voir ma fiche
</a>
</Link>
)}
</div>
</header>
@@ -302,6 +311,7 @@ const DashboardContent = () => {
{currentView === 'overview' && <DashboardOverview business={displayBusiness} stats={stats} />}
{currentView === 'messages' && <DashboardMessages onMessagesRead={fetchUnreadCount} initialConversationId={chatId} />}
{currentView === 'personal-profile' && <DashboardProfileClient />}
{currentView === 'favorites' && <DashboardFavorites />}
{currentView === 'profile' && <DashboardProfile business={displayBusiness} setBusiness={setBusiness} />}
{currentView === 'offers' && <DashboardOffers businessId={displayBusiness.id} plan={displayBusiness.plan} />}
{currentView === 'subscription' && (

View File

@@ -0,0 +1,113 @@
"use client";
import React, { useState } from 'react';
import Link from 'next/link';
import { useUser } from '@/components/UserProvider';
const ForgotPasswordPage = () => {
const { settings } = useUser();
const siteName = settings?.siteName || "Afrohub";
// Fetch CSRF token on mount
React.useEffect(() => {
fetch('/api/auth/csrf')
.then(res => res.json())
.then(data => setCsrfToken(data.csrfToken))
.catch(err => console.error('Error fetching CSRF token:', err));
}, []);
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [csrfToken, setCsrfToken] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
setMessage('');
try {
const res = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
body: JSON.stringify({ email }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Une erreur est survenue');
}
setMessage(data.message);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-[80vh] flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<div className="mx-auto h-12 w-12 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
{siteName.charAt(0)}
</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 font-serif">Mot de passe oublié</h2>
<p className="mt-2 text-sm text-gray-600">
Entrez votre adresse email pour recevoir un lien de réinitialisation
</p>
</div>
{message ? (
<div className="bg-green-50 border-l-4 border-green-500 p-4 rounded text-green-700 text-sm">
{message}
<div className="mt-4">
<Link href="/login" className="font-medium text-brand-600 hover:text-brand-500"> Retourner à la connexion</Link>
</div>
</div>
) : (
<form className="mt-8 space-y-6" onSubmit={handleSubmit} method="POST">
<input type="hidden" name="csrf_token" value={csrfToken} />
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm">
{error}
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<input
type="email"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-brand-500 focus:border-brand-500 focus:z-10 sm:text-sm"
placeholder="Adresse email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70"
>
{loading ? 'Envoi...' : 'Envoyer le lien'}
</button>
</div>
<div className="text-center">
<Link href="/login" className="font-medium text-brand-600 hover:text-brand-500 text-sm"> Retourner à la connexion</Link>
</div>
</form>
)}
</div>
</div>
);
};
export default ForgotPasswordPage;

View File

@@ -2,8 +2,8 @@
@plugin "@tailwindcss/typography";
@theme {
--font-sans: 'Inter', sans-serif;
--font-serif: 'Playfair Display', serif;
--font-sans: var(--font-inter), sans-serif;
--font-serif: var(--font-playfair), serif;
--color-brand-50: #fff7ed;
--color-brand-100: #ffedd5;
@@ -24,3 +24,15 @@ body {
-moz-osx-font-smoothing: grayscale;
font-family: var(--font-sans);
}
.prose {
text-align: justify;
hyphens: none;
word-break: normal;
overflow-wrap: normal;
overflow-x: auto;
}
.prose p {
margin-bottom: 1.25em;
}

View File

@@ -8,28 +8,58 @@ import CookieBanner from '../components/CookieBanner';
import { Metadata } from 'next';
import { Toaster } from 'react-hot-toast';
import { getSiteSettings } from '../lib/settings';
import { Inter, Playfair_Display } from 'next/font/google';
import './globals.css';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
const playfair = Playfair_Display({
subsets: ['latin'],
variable: '--font-playfair',
display: 'swap',
});
export const dynamic = 'force-dynamic';
export async function generateMetadata(): Promise<Metadata> {
const settings = await getSiteSettings();
return {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
title: `${settings.siteName} - L'Annuaire 2.0`,
description: settings.siteSlogan,
description: settings.siteSlogan || "Découvrez les pépites de l'écosystème africain.",
alternates: {
canonical: '/',
},
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const settings = await getSiteSettings();
const isDev = process.env.NEXT_PUBLIC_APP_ENV === 'development';
return (
<html lang="fr">
<html lang="fr" className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
<head>
<link rel="icon" type="image/svg+xml" href="https://cdn-icons-png.flaticon.com/512/1022/1022235.png" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" />
<link rel="icon" type="image/png" href="/favicon.png" />
{isDev && (
<style dangerouslySetInnerHTML={{ __html: `
:root {
--color-brand-50: #fef2f2 !important;
--color-brand-100: #fee2e2 !important;
--color-brand-200: #fecaca !important;
--color-brand-500: #ef4444 !important;
--color-brand-600: #dc2626 !important;
--color-brand-700: #b91c1c !important;
--color-brand-900: #7f1d1d !important;
}
`}} />
)}
</head>
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans">
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans" suppressHydrationWarning>
<UserProvider>
<Toaster position="top-right" />
<AnalyticsTracker />

View File

@@ -2,9 +2,10 @@
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useUser } from '../../components/UserProvider';
import { CheckCircle2, AlertCircle } from 'lucide-react';
const LoginPage = () => {
const { settings, login } = useUser();
@@ -12,24 +13,48 @@ const LoginPage = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isUnverified, setIsUnverified] = useState(false);
const [loading, setLoading] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [resendMessage, setResendMessage] = useState('');
const [csrfToken, setCsrfToken] = useState('');
const router = useRouter();
const searchParams = useSearchParams();
const verified = searchParams.get('verified') === 'true';
const queryError = searchParams.get('error');
// Fetch CSRF token on mount
React.useEffect(() => {
fetch('/api/auth/csrf')
.then(res => res.json())
.then(data => setCsrfToken(data.csrfToken))
.catch(err => console.error('Error fetching CSRF token:', err));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
setIsUnverified(false);
setResendMessage('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
if (res.status === 403 && data.error?.includes('vérifier')) {
setIsUnverified(true);
}
throw new Error(data.error || 'Erreur lors de la connexion');
}
@@ -43,6 +68,36 @@ const LoginPage = () => {
}
};
const handleResendVerification = async () => {
setResendLoading(true);
setError('');
setResendMessage('');
try {
const res = await fetch('/api/auth/resend-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
body: JSON.stringify({ email }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Erreur lors de l'envoi");
}
setResendMessage(data.message);
setIsUnverified(false); // Hide the resend link after successful send
} catch (err: any) {
setError(err.message);
} finally {
setResendLoading(false);
}
};
return (
<div className="min-h-[80vh] flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
@@ -55,12 +110,43 @@ const LoginPage = () => {
Ou <Link href="/register" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm">
{error}
<div className="mt-8 space-y-6">
{verified && (
<div className="bg-emerald-50 border-l-4 border-emerald-500 p-4 rounded flex items-center gap-3 text-emerald-700 text-sm">
<CheckCircle2 className="w-5 h-5 text-emerald-500 flex-shrink-0" />
<p>Votre compte a é vérifié avec succès ! Vous pouvez maintenant vous connecter.</p>
</div>
)}
{resendMessage && (
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded flex items-center gap-3 text-blue-700 text-sm">
<CheckCircle2 className="w-5 h-5 text-blue-500 flex-shrink-0" />
<p>{resendMessage}</p>
</div>
)}
{(error || queryError) && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded flex flex-col gap-2 text-red-700 text-sm">
<div className="flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
<p>{error || queryError}</p>
</div>
{isUnverified && (
<button
type="button"
onClick={handleResendVerification}
disabled={resendLoading}
className="ml-8 text-left font-bold underline hover:text-red-800 disabled:opacity-50"
>
{resendLoading ? 'Envoi en cours...' : "Renvoyer l'email de vérification"}
</button>
)}
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit} method="POST">
<input type="hidden" name="csrf_token" value={csrfToken} />
<div className="rounded-md shadow-sm -space-y-px">
<div>
<input
@@ -83,6 +169,13 @@ const LoginPage = () => {
/>
</div>
</div>
<div className="flex items-center justify-end">
<div className="text-sm">
<Link href="/forgot-password" title="Réinitialiser votre mot de passe" className="font-medium text-brand-600 hover:text-brand-500">
Mot de passe oublié ?
</Link>
</div>
</div>
<div>
<button
type="submit"
@@ -95,6 +188,7 @@ const LoginPage = () => {
</form>
</div>
</div>
</div>
);
};

50
app/not-found.tsx Normal file
View File

@@ -0,0 +1,50 @@
import React from 'react';
import Link from 'next/link';
import { Home, ArrowLeft, Search } from 'lucide-react';
export default function NotFound() {
return (
<div className="min-h-screen bg-white flex items-center justify-center px-4 sm:px-6 lg:px-8 py-24">
<div className="max-w-md w-full text-center">
{/* Animated 404 number */}
<div className="relative mb-8">
<h1 className="text-9xl font-serif font-black text-gray-100 select-none">404</h1>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-4xl md:text-5xl font-serif font-bold text-brand-600">Oups !</span>
</div>
</div>
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-4 font-serif">
Page introuvable
</h2>
<p className="text-gray-500 mb-10 text-lg">
Il semblerait que le chemin que vous avez emprunté n'existe pas ou a été déplacé.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/"
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-full text-white bg-brand-600 hover:bg-brand-700 shadow-lg hover:shadow-xl transition-all"
>
<Home className="w-5 h-5 mr-2" />
Retour à l'accueil
</Link>
<Link
href="/annuaire"
className="inline-flex items-center justify-center px-6 py-3 border border-gray-200 text-base font-medium rounded-full text-gray-700 bg-white hover:bg-gray-50 shadow-sm transition-all"
>
<Search className="w-5 h-5 mr-2" />
Explorer l'annuaire
</Link>
</div>
<div className="mt-12 pt-12 border-t border-gray-100">
<p className="text-sm text-gray-400 italic">
"L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,205 +1,76 @@
"use client";
import React from 'react';
import { prisma } from '@/lib/prisma';
import HomeClient from './HomeClient';
import { Metadata } from 'next';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
import { Business, BlogPost } from '../types';
import BusinessCard from '../components/BusinessCard';
import { useUser } from '../components/UserProvider';
export async function generateMetadata(): Promise<Metadata> {
const settings = await prisma.siteSetting.findUnique({
where: { id: 'singleton' }
});
const HomePage = () => {
const router = useRouter();
const { settings } = useUser();
const [searchTerm, setSearchTerm] = useState('');
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>([]);
const [posts, setPosts] = useState<BlogPost[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [loadingPosts, setLoadingPosts] = useState(true);
const title = settings?.siteName || 'Afroprenariat';
const description = settings?.siteSlogan || "La plateforme de référence pour l'entrepreneuriat africain.";
useEffect(() => {
const fetchFeatured = async () => {
try {
const res = await fetch('/api/businesses?featured=true');
const data = await res.json();
if (Array.isArray(data)) {
setFeaturedBusinesses(data.slice(0, 4)); // Show top 4
}
} catch (error) {
console.error('Error fetching featured businesses:', error);
} finally {
setLoading(false);
}
return {
title: {
default: title,
template: `%s | ${title}`
},
description: description,
openGraph: {
title: title,
description: description,
url: 'https://afroprenariat.com',
siteName: title,
images: [
{
url: 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&h=630&q=80',
width: 1200,
height: 630,
alt: title,
},
],
locale: 'fr_FR',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: title,
description: description,
images: ['https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&h=630&q=80'],
},
};
fetchFeatured();
}
const fetchPosts = async () => {
try {
const res = await fetch('/api/blog');
const data = await res.json();
if (Array.isArray(data)) {
setPosts(data);
}
} catch (error) {
console.error('Error fetching blog posts:', error);
} finally {
setLoadingPosts(false);
}
};
const fetchCategories = async () => {
try {
const res = await fetch('/api/categories');
const data = await res.json();
if (Array.isArray(data)) setCategories(data);
} catch (e) {}
};
fetchPosts();
fetchCategories();
}, []);
export default async function HomePage() {
// Fetch initial data for faster loading and SEO
const [featuredBusinesses, posts, categories] = await Promise.all([
prisma.business.findMany({
where: { isFeatured: true, isActive: true, isSuspended: false },
take: 4,
include: { categoryRef: true }
}),
prisma.blogPost.findMany({
where: { status: 'PUBLISHED', publishedAt: { lte: new Date() } },
orderBy: { date: 'desc' },
take: 6
}),
prisma.businessCategory.findMany({
where: { isActive: true },
take: 8
})
]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
router.push(`/annuaire?q=${searchTerm}`);
};
// Convert to plain objects for client component
const initialFeatured = JSON.parse(JSON.stringify(featuredBusinesses));
const initialPosts = JSON.parse(JSON.stringify(posts));
const initialCategories = JSON.parse(JSON.stringify(categories));
return (
<div>
{/* Hero Section */}
<div className="relative bg-dark-900 overflow-hidden">
<div className="absolute inset-0 opacity-40">
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
Boostez votre visibilité dans <br />
<span className="text-brand-500">l'écosystème africain</span>
</h1>
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
</p>
<form onSubmit={handleSearch} className="max-w-3xl bg-white p-2 rounded-lg shadow-xl flex flex-col md:flex-row gap-2">
<div className="flex-1 relative">
<Search className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Que recherchez-vous ? (ex: Développeur, Traiteur...)"
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
<HomeClient
initialFeatured={initialFeatured}
initialPosts={initialPosts}
initialCategories={initialCategories}
/>
</div>
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
<MapPin className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input type="text" placeholder="Localisation (ex: Abidjan)" className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900" />
</div>
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
Rechercher
</button>
</form>
</div>
</div>
{/* Featured Categories */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<h2 className="text-3xl font-bold text-gray-900 mb-10 font-serif text-center">Secteurs en vedette</h2>
<div className="flex flex-wrap justify-center gap-4 sm:gap-6">
{(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
<Link
key={idx}
href={`/annuaire?category=${encodeURIComponent(cat)}`}
className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center w-[calc(50%-0.5rem)] sm:w-48 lg:w-56 flex flex-col items-center"
>
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
<Briefcase className="w-6 h-6" />
</div>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base line-clamp-2">{cat}</h3>
</Link>
))}
</div>
</div>
{/* Featured Businesses */}
<div className="bg-gray-50 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">Entreprises à la une</h2>
<p className="text-gray-500 mt-2">Découvrez les pépites de notre communauté.</p>
</div>
<Link href="/annuaire" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
</Link>
</div>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-10 h-10 text-brand-600 animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{featuredBusinesses.map(biz => (
<BusinessCard key={biz.id} business={biz} />
))}
</div>
)}
</div>
</div>
{/* Blog Section */}
{settings?.homeBlogShow !== false && (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
</div>
<Link href="/blog" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout le blog <ArrowRight className="w-4 h-4 ml-1" />
</Link>
</div>
{loadingPosts ? (
<div className="flex justify-center py-12">
<Loader2 className="w-10 h-10 text-brand-600 animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts
.filter(post => {
if (settings?.homeBlogSelection === 'manual') {
return settings.homeBlogIds?.includes(post.id);
}
if (settings?.homeBlogSelection === 'category') {
return post.tags?.some((tag: string) => settings.homeBlogCategories?.includes(tag));
}
return true;
})
.slice(0, settings?.homeBlogCount || 3)
.map(post => (
<Link key={post.id} href={`/blog/${post.slug || post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
<div className="h-48 overflow-hidden">
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div>
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-brand-600 transition-colors">{post.title}</h3>
<p className="text-gray-500 text-sm line-clamp-3 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between mt-auto pt-4 border-t border-gray-50">
<span className="text-xs font-medium text-gray-400">{new Date(post.date).toLocaleDateString('fr-FR')}</span>
<span className="text-brand-600 font-bold text-xs">Lire la suite </span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
)}
</div>
);
};
export default HomePage;
}

View File

@@ -19,12 +19,21 @@ const RegisterPage = () => {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [csrfToken, setCsrfToken] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
setError('');
};
// Fetch CSRF token on mount
React.useEffect(() => {
fetch('/api/auth/csrf')
.then(res => res.json())
.then(data => setCsrfToken(data.csrfToken))
.catch(err => console.error('Error fetching CSRF token:', err));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -39,7 +48,10 @@ const RegisterPage = () => {
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
body: JSON.stringify({
name: formData.name,
email: formData.email,
@@ -86,7 +98,8 @@ const RegisterPage = () => {
<p className="text-gray-600">Redirection vers la page de connexion...</p>
</div>
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
<form className="space-y-6" onSubmit={handleSubmit} method="POST">
<input type="hidden" name="csrf_token" value={csrfToken} />
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded-r-lg flex items-center gap-3 text-red-700 animate-in slide-in-from-top-2 duration-300">
<AlertCircle size={20} className="shrink-0" />

151
app/reset-password/page.tsx Normal file
View File

@@ -0,0 +1,151 @@
"use client";
import React, { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useUser } from '@/components/UserProvider';
const ResetPasswordForm = () => {
const { settings } = useUser();
const siteName = settings?.siteName || "Afrohub";
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [csrfToken, setCsrfToken] = useState('');
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
// Fetch CSRF token on mount
React.useEffect(() => {
fetch('/api/auth/csrf')
.then(res => res.json())
.then(data => setCsrfToken(data.csrfToken))
.catch(err => console.error('Error fetching CSRF token:', err));
}, []);
useEffect(() => {
if (!token) {
setError("Le lien de réinitialisation est invalide ou manquant.");
}
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
setError("Les mots de passe ne correspondent pas.");
return;
}
if (password.length < 6) {
setError("Le mot de passe doit faire au moins 6 caractères.");
return;
}
setLoading(true);
setError('');
setMessage('');
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Une erreur est survenue');
}
setMessage(data.message);
setTimeout(() => {
router.push('/login');
}, 3000);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-[80vh] flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<div className="mx-auto h-12 w-12 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
{siteName.charAt(0)}
</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 font-serif">Nouveau mot de passe</h2>
<p className="mt-2 text-sm text-gray-600">
Choisissez votre nouveau mot de passe
</p>
</div>
{message ? (
<div className="bg-green-50 border-l-4 border-green-500 p-4 rounded text-green-700 text-sm">
{message}
<p className="mt-2">Redirection vers la page de connexion...</p>
</div>
) : (
<form className="mt-8 space-y-6" onSubmit={handleSubmit} method="POST">
<input type="hidden" name="csrf_token" value={csrfToken} />
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm">
{error}
</div>
)}
<div className="rounded-md shadow-sm space-y-2">
<div>
<input
type="password"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-brand-500 focus:border-brand-500 focus:z-10 sm:text-sm"
placeholder="Nouveau mot de passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div>
<input
type="password"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-brand-500 focus:border-brand-500 focus:z-10 sm:text-sm"
placeholder="Confirmer le mot de passe"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading || !token}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70"
>
{loading ? 'Mise à jour...' : 'Mettre à jour le mot de passe'}
</button>
</div>
</form>
)}
</div>
</div>
);
};
const ResetPasswordPage = () => {
return (
<Suspense fallback={<div>Chargement...</div>}>
<ResetPasswordForm />
</Suspense>
);
};
export default ResetPasswordPage;

21
app/robots.ts Normal file
View File

@@ -0,0 +1,21 @@
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://afroprenariat.com';
return {
rules: {
userAgent: '*',
allow: '/',
disallow: [
'/login',
'/register',
'/dashboard',
'/subscription',
'/api',
'/suspended',
],
},
sitemap: `${baseUrl}/sitemap.xml`,
};
}

117
app/sitemap.ts Normal file
View File

@@ -0,0 +1,117 @@
import { MetadataRoute } from 'next';
import prisma from '@/lib/prisma';
export const dynamic = 'force-dynamic';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://afroprenariat.com';
// 1. Static routes
const staticRoutes = [
{
url: `${baseUrl}`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1.0,
},
{
url: `${baseUrl}/annuaire`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.9,
},
{
url: `${baseUrl}/blog`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.9,
},
{
url: `${baseUrl}/afrolife`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.8,
},
{
url: `${baseUrl}/cgu`,
lastModified: new Date(),
changeFrequency: 'yearly' as const,
priority: 0.3,
},
{
url: `${baseUrl}/cgv`,
lastModified: new Date(),
changeFrequency: 'yearly' as const,
priority: 0.3,
},
];
try {
// 2. Fetch all active businesses
const businesses = await prisma.business.findMany({
where: { isActive: true, isSuspended: false },
select: { id: true, slug: true, updatedAt: true },
});
const businessRoutes = businesses.map((business) => ({
url: `${baseUrl}/annuaire/${business.slug || business.id}`,
lastModified: business.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
// 3. Fetch all blog posts
const blogPosts = await prisma.blogPost.findMany({
where: {
status: 'PUBLISHED',
publishedAt: { lte: new Date() }
},
select: { id: true, slug: true, updatedAt: true },
});
const blogRoutes = blogPosts.map((post) => ({
url: `${baseUrl}/blog/${post.slug || post.id}`,
lastModified: post.updatedAt,
changeFrequency: 'monthly' as const,
priority: 0.7,
}));
// 4. Fetch all Afro Life (Interviews)
const interviews = await prisma.interview.findMany({
where: {
status: 'PUBLISHED',
publishedAt: { lte: new Date() }
},
select: { id: true, slug: true, updatedAt: true },
});
const afroLifeRoutes = interviews.map((interview) => ({
url: `${baseUrl}/afrolife/${interview.slug || interview.id}`,
lastModified: interview.updatedAt,
changeFrequency: 'monthly' as const,
priority: 0.7,
}));
// 5. Fetch all Events
const events = await prisma.event.findMany({
where: {
status: 'PUBLISHED',
publishedAt: { lte: new Date() }
},
select: { id: true, slug: true, updatedAt: true },
});
const eventRoutes = events.map((event) => ({
url: `${baseUrl}/afrolife/${event.slug || event.id}`,
lastModified: event.updatedAt,
changeFrequency: 'monthly' as const,
priority: 0.7,
}));
return [...staticRoutes, ...businessRoutes, ...blogRoutes, ...afroLifeRoutes, ...eventRoutes];
} catch (error) {
console.error("Error generating sitemap:", error);
// Fallback to only static routes if DB connection fails
return staticRoutes;
}
}

View File

@@ -49,7 +49,7 @@ const AnnuaireHero = ({ featuredBusiness }: { featuredBusiness: Business }) => {
<div className="md:w-3/5 p-8 md:p-12 flex flex-col justify-center">
<div className="flex items-center space-x-2 mb-4">
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-yellow-100 text-yellow-800 uppercase tracking-wider">
<Award className="w-3 h-3 mr-1" /> Entrepreneur du mois
<Award className="w-3 h-3 mr-1" /> Afroshine
</span>
</div>
@@ -81,7 +81,7 @@ const AnnuaireHero = ({ featuredBusiness }: { featuredBusiness: Business }) => {
Voir la fiche complète
</Link>
<button className="inline-flex items-center justify-center px-6 py-3 border-2 border-gray-200 text-base font-medium rounded-md text-gray-600 bg-transparent hover:border-brand-300 hover:text-brand-600 transition-colors">
Devenir l'entrepreneur du mois <ArrowRight className="w-4 h-4 ml-2"/>
Devenir l'Afroshine <ArrowRight className="w-4 h-4 ml-2"/>
</button>
</div>
</div>

View File

@@ -3,12 +3,47 @@
import React, { useState } from 'react';
import Link from 'next/link';
import { CheckCircle, MapPin, Star, Phone, Share2, Facebook, Linkedin, Instagram, Twitter } from 'lucide-react';
import { CheckCircle, MapPin, Star, Phone, Share2, Facebook, Linkedin, Instagram, Twitter, Heart } from 'lucide-react';
import { useUser } from './UserProvider';
import { Business } from '../types';
import { toast } from 'react-hot-toast';
const BusinessCard: React.FC<{ business: Business }> = ({ business }) => {
const { user } = useUser();
const [isShareOpen, setIsShareOpen] = useState(false);
const [isFavorited, setIsFavorited] = useState(false);
const [isToggling, setIsToggling] = useState(false);
const toggleFavorite = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!user) {
toast.error("Connectez-vous pour ajouter des favoris");
return;
}
setIsToggling(true);
try {
const res = await fetch('/api/favorites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user.id
},
body: JSON.stringify({ businessId: business.id })
});
const data = await res.json();
if (!data.error) {
setIsFavorited(data.favorited);
toast.success(data.favorited ? 'Ajouté aux favoris' : 'Retiré des favoris');
}
} catch (error) {
toast.error('Erreur réseau');
} finally {
setIsToggling(false);
}
};
const toggleShare = (e: React.MouseEvent) => {
e.preventDefault();
@@ -52,6 +87,9 @@ const BusinessCard: React.FC<{ business: Business }> = ({ business }) => {
src={`https://picsum.photos/seed/${business.id}/500/300`}
alt="Couverture"
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
width={500}
height={300}
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
@@ -67,6 +105,14 @@ const BusinessCard: React.FC<{ business: Business }> = ({ business }) => {
<Phone className="w-4 h-4" />
</a>
)}
<button
onClick={toggleFavorite}
disabled={isToggling}
className={`p-2 backdrop-blur-md border rounded-full transition-all shadow-sm ${isFavorited ? 'bg-red-500 border-red-500 text-white' : 'bg-white/20 border-white/20 text-white hover:bg-white hover:text-red-500'}`}
title={isFavorited ? "Retirer des favoris" : "Ajouter aux favoris"}
>
<Heart className={`w-4 h-4 ${isFavorited ? 'fill-current' : ''}`} />
</button>
<div className="relative">
<button
onClick={toggleShare}
@@ -98,7 +144,7 @@ const BusinessCard: React.FC<{ business: Business }> = ({ business }) => {
{/* Logo */}
<div className="absolute -bottom-6 left-4 z-10 w-16 h-16 bg-white rounded-lg p-1 shadow-md">
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-md bg-gray-50" />
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-md bg-gray-50" width={64} height={64} loading="lazy" />
</div>
<Link href={`/annuaire/${business.slug || business.id}`} className="absolute inset-0 z-0" aria-label={`Voir ${business.name}`} />

View File

@@ -0,0 +1,14 @@
"use client";
import React from 'react';
import dynamic from 'next/dynamic';
// On importe les Providers reellement en mode Client-Only (pas de SSR)
const Providers = dynamic(
() => import('./Providers').then((mod) => mod.Providers),
{ ssr: false }
);
export function DynamicProviders({ children }: { children: React.ReactNode }) {
return <Providers>{children}</Providers>;
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Calendar, MapPin, ArrowRight, Clock } from 'lucide-react';
import Link from 'next/link';
import { sanitizeHTML } from '../lib/sanitize';
interface Event {
id: string;
@@ -74,7 +75,7 @@ const EventTimeline = ({ events }: EventTimelineProps) => {
</div>
<div
className="text-gray-600 text-sm line-clamp-2 prose-sm"
dangerouslySetInnerHTML={{ __html: event.description }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(event.description) }}
/>
<div className="pt-4 flex items-center justify-between">

View File

@@ -68,6 +68,7 @@ const Footer = ({ settings }: FooterProps) => {
<li><Link href="/annuaire" className="hover:text-brand-500">Rechercher une entreprise</Link></li>
<li><Link href="/login" className="hover:text-brand-500">Inscrire mon entreprise</Link></li>
<li><Link href="/blog" className="hover:text-brand-500">Actualités & Conseils</Link></li>
<li><Link href="/subscription" className="hover:text-brand-500">Tarifs</Link></li>
</ul>
</div>
<div>

View File

@@ -16,12 +16,13 @@ const Navbar = ({ settings }: NavbarProps) => {
const pathname = usePathname() || '';
const siteName = settings?.siteName || "Afrohub";
const isDev = process.env.NEXT_PUBLIC_APP_ENV === 'development';
// Hide Navbar on Dashboard to prevent double navigation, show only on public pages
if (pathname.startsWith('/dashboard')) return null;
return (
<nav className="bg-white shadow-sm sticky top-0 z-50">
<nav className={`shadow-sm sticky top-0 z-50 ${isDev ? 'bg-red-600/90 backdrop-blur-sm' : 'bg-white'}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
@@ -29,19 +30,19 @@ const Navbar = ({ settings }: NavbarProps) => {
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">
{siteName.charAt(0)}
</div>
<span className="font-serif font-bold text-xl text-gray-900">{siteName}</span>
<span className={`font-serif font-bold text-xl ${isDev ? 'text-white' : 'text-gray-900'}`}>{siteName}</span>
</Link>
<div className="hidden sm:ml-8 sm:flex sm:space-x-8">
<Link href="/" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname === '/' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
<Link href="/" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname === '/' ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
Accueil
</Link>
<Link href="/annuaire" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/annuaire') ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
<Link href="/annuaire" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/annuaire') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
Annuaire
</Link>
<Link href="/afrolife" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/afrolife') ? 'border-brand-500 text-brand-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
<Link href="/afrolife" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/afrolife') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-brand-600') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
Afro Life
</Link>
<Link href="/blog" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/blog') ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
<Link href="/blog" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/blog') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
Blog
</Link>
</div>
@@ -55,14 +56,14 @@ const Navbar = ({ settings }: NavbarProps) => {
</Link>
</>
) : (
<Link href="/login" className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
<Link href="/login" className={`inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md focus:outline-none ${isDev ? 'border-white/30 text-white bg-white/10 hover:bg-white/20' : 'border-gray-300 text-gray-700 bg-white hover:bg-gray-50'}`}>
<UserIcon className="w-4 h-4 mr-2" />
Connexion / Inscription
</Link>
)}
</div>
<div className="-mr-2 flex items-center sm:hidden">
<button onClick={() => setIsOpen(!isOpen)} className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none">
<button onClick={() => setIsOpen(!isOpen)} className={`inline-flex items-center justify-center p-2 rounded-md focus:outline-none ${isDev ? 'text-white hover:text-red-100 hover:bg-white/10' : 'text-gray-400 hover:text-gray-500 hover:bg-gray-100'}`}>
{isOpen ? <X className="block h-6 w-6" /> : <Menu className="block h-6 w-6" />}
</button>
</div>
@@ -71,7 +72,7 @@ const Navbar = ({ settings }: NavbarProps) => {
{/* Mobile menu */}
{isOpen && (
<div className="sm:hidden bg-white border-t">
<div className={`sm:hidden border-t ${isDev ? 'bg-red-600/95 border-red-500/30' : 'bg-white'}`}>
<div className="pt-2 pb-3 space-y-1">
<Link href="/" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Accueil</Link>
<Link href="/annuaire" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Annuaire</Link>

18
components/Providers.tsx Normal file
View File

@@ -0,0 +1,18 @@
"use client";
import React from 'react';
import { Toaster } from 'react-hot-toast';
import { UserProvider } from './UserProvider';
import AnalyticsTracker from './AnalyticsTracker';
import CookieBanner from './CookieBanner';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<UserProvider>
<Toaster position="top-right" />
<AnalyticsTracker />
{children}
<CookieBanner />
</UserProvider>
);
}

181
components/TagInput.tsx Normal file
View File

@@ -0,0 +1,181 @@
'use client';
import React, { useState, KeyboardEvent, useEffect, useRef } from 'react';
import { X, Hash, Search, TrendingUp } from 'lucide-react';
interface Props {
value: string[];
onChange: (value: string[]) => void;
label?: string;
placeholder?: string;
suggestions?: string[];
onlySuggestions?: boolean;
}
export default function TagInput({ value, onChange, label, placeholder, suggestions = [], onlySuggestions = false }: Props) {
const [inputValue, setInputValue] = useState('');
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inputValue.trim()) {
const filtered = suggestions
.filter(tag =>
tag.toLowerCase().includes(inputValue.toLowerCase()) &&
!value.includes(tag.toLowerCase())
)
.slice(0, 10);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else if (showSuggestions && suggestions.length > 0) {
const filtered = suggestions
.filter(tag => !value.includes(tag.toLowerCase()))
.slice(0, 8);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else {
setFilteredSuggestions([]);
setShowSuggestions(false);
}
setSelectedIndex(-1);
}, [inputValue, suggestions, value, showSuggestions]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const addTag = (tagToAdd?: string) => {
if (onlySuggestions && !tagToAdd) return;
const tag = (tagToAdd || inputValue).trim().toLowerCase();
// If restricted, check if tag exists in suggestions
if (onlySuggestions && tagToAdd && !suggestions.some(s => s.toLowerCase() === tag)) {
return;
}
if (tag && !value.includes(tag)) {
onChange([...value, tag]);
}
setInputValue('');
if (tagToAdd) setShowSuggestions(true);
};
const removeTag = (tagToRemove: string) => {
onChange(value.filter(tag => tag !== tagToRemove));
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
addTag(filteredSuggestions[selectedIndex]);
} else if (!onlySuggestions) {
addTag();
}
} else if ((e.key === ',' || e.key === ';') && !onlySuggestions) {
e.preventDefault();
addTag();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (!showSuggestions && suggestions.length > 0) {
setShowSuggestions(true);
} else {
setSelectedIndex(prev => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev));
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => (prev > -1 ? prev - 1 : -1));
} else if (e.key === 'Escape') {
setShowSuggestions(false);
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
removeTag(value[value.length - 1]);
}
};
return (
<div className="space-y-2 relative" ref={containerRef}>
{label && <label className="text-sm font-medium text-gray-700">{label}</label>}
<div
className="min-h-[48px] p-2 bg-white border border-gray-300 rounded-lg flex flex-wrap gap-2 items-center focus-within:border-brand-500 focus-within:ring-1 focus-within:ring-brand-500 transition-all cursor-text shadow-sm"
onClick={() => {
const input = containerRef.current?.querySelector('input');
input?.focus();
}}
>
{value.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100 px-2.5 py-1 rounded-md text-sm font-medium animate-in zoom-in-95 duration-200"
>
<Hash className="w-3 h-3 opacity-50" />
{tag}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
className="hover:text-red-500 transition-colors ml-1"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(true)}
placeholder={value.length === 0 ? (placeholder || "Ajouter des tags...") : ""}
className="flex-1 bg-transparent border-none outline-none text-gray-900 text-sm min-w-[120px] p-1 placeholder:text-gray-400"
/>
</div>
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest flex items-center gap-1.5">
{inputValue ? <Search className="w-3 h-3" /> : <TrendingUp className="w-3 h-3" />}
{inputValue ? 'Suggestions' : 'Tags populaires'}
</p>
</div>
<div className="max-h-60 overflow-y-auto">
{filteredSuggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
onClick={(e) => {
e.stopPropagation();
addTag(suggestion);
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
index === selectedIndex ? 'bg-brand-600 text-white' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<span className="flex items-center gap-2">
<Hash className={`w-3.5 h-3.5 ${index === selectedIndex ? 'text-white/50' : 'text-gray-400'}`} />
{suggestion}
</span>
{index === selectedIndex && (
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded font-bold uppercase">Entrée</span>
)}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,12 +1,12 @@
"use client";
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { User } from '../types';
import { User, SiteSetting } from '../types';
import { useRouter, usePathname } from 'next/navigation';
interface UserContextType {
user: User | null;
settings: any | null;
settings: SiteSetting | null;
login: (user: User) => void;
logout: () => void;
}
@@ -15,7 +15,7 @@ const UserContext = createContext<UserContextType | undefined>(undefined);
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [settings, setSettings] = useState<any>(null);
const [settings, setSettings] = useState<SiteSetting | null>(null);
const router = useRouter();
const pathname = usePathname();

View File

@@ -0,0 +1,171 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Heart, Loader2, Search, MapPin, Star, ExternalLink } from 'lucide-react';
import Link from 'next/link';
import { useUser } from '../UserProvider';
import { toast } from 'react-hot-toast';
interface Favorite {
id: string;
business: {
id: string;
name: string;
category: string;
location: string;
description: string;
logoUrl: string;
slug: string | null;
rating: number;
viewCount: number;
}
}
const DashboardFavorites = () => {
const { user } = useUser();
const [favorites, setFavorites] = useState<Favorite[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
if (user) {
fetchFavorites();
}
}, [user]);
const fetchFavorites = async () => {
try {
const res = await fetch('/api/favorites', {
headers: { 'x-user-id': user?.id || '' }
});
const data = await res.json();
if (!data.error) {
setFavorites(data);
}
} catch (error) {
console.error('Error fetching favorites:', error);
} finally {
setLoading(false);
}
};
const handleRemoveFavorite = async (e: React.MouseEvent, businessId: string) => {
e.preventDefault();
e.stopPropagation();
try {
const res = await fetch('/api/favorites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user?.id || ''
},
body: JSON.stringify({ businessId })
});
const data = await res.json();
if (!data.error) {
setFavorites(favorites.filter(f => f.business.id !== businessId));
toast.success('Retiré des favoris');
}
} catch (error) {
toast.error('Erreur lors de la suppression');
}
};
const filteredFavorites = favorites.filter(f =>
f.business.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
f.business.category.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h2 className="text-2xl font-bold font-serif text-gray-900">Mes Favoris</h2>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none w-full sm:w-64"
/>
</div>
</div>
{filteredFavorites.length === 0 ? (
<div className="bg-white rounded-xl border border-dashed border-gray-300 p-12 text-center">
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mx-auto mb-4">
<Heart className="w-8 h-8 text-gray-200" />
</div>
<h3 className="text-lg font-bold text-gray-900 mb-1">Aucun favori</h3>
<p className="text-gray-500 max-w-sm mx-auto mb-6">
Vous n'avez pas encore ajouté d'entrepreneur à vos favoris. Parcourez l'annuaire pour découvrir des pépites !
</p>
<Link
href="/annuaire"
className="inline-flex items-center px-4 py-2 bg-brand-600 text-white font-bold rounded-lg hover:bg-brand-700 transition-colors"
>
Explorer l'annuaire
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredFavorites.map((fav) => (
<div key={fav.id} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden group flex flex-col h-full relative">
<div className="relative h-32 bg-gray-100">
<img
src={fav.business.logoUrl || `https://picsum.photos/seed/${fav.business.id}/400/200`}
alt={fav.business.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/40 transition-colors" />
<button
onClick={(e) => handleRemoveFavorite(e, fav.business.id)}
className="absolute top-2 right-2 p-2 bg-white rounded-full text-red-500 shadow-sm hover:scale-110 transition-transform"
title="Retirer des favoris"
>
<Heart className="w-4 h-4 fill-current" />
</button>
</div>
<div className="p-4 flex-1 flex flex-col">
<h4 className="font-bold text-gray-900 truncate mb-1">{fav.business.name}</h4>
<p className="text-[10px] text-brand-600 font-bold uppercase tracking-wider mb-2">{fav.business.category}</p>
<div className="flex items-center text-xs text-gray-500 mb-2">
<MapPin className="w-3.5 h-3.5 mr-1" />
{fav.business.location}
</div>
<div className="flex items-center gap-3 mt-auto pt-3 border-t border-gray-50">
<div className="flex items-center">
<Star className="w-3 h-3 text-yellow-400 fill-current" />
<span className="text-[11px] font-bold ml-1">{fav.business.rating}</span>
</div>
<Link
href={`/annuaire/${fav.business.slug || fav.business.id}`}
className="ml-auto text-[11px] font-bold text-brand-600 flex items-center gap-1 hover:underline"
>
Voir la fiche
<ExternalLink className="w-3 h-3" />
</Link>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default DashboardFavorites;

View File

@@ -112,7 +112,7 @@ const DashboardOverview = ({ business, stats }: {
<div className="px-2 py-1 bg-brand-50 text-brand-700 text-[10px] font-bold uppercase rounded leading-none">30 derniers jours</div>
</div>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minWidth={0}>
<AreaChart data={stats?.dailyStats || []}>
<defs>
<linearGradient id="colorViews" x1="0" y1="0" x2="0" y2="1">
@@ -154,7 +154,7 @@ const DashboardOverview = ({ business, stats }: {
<div className="px-2 py-1 bg-blue-50 text-blue-700 text-[10px] font-bold uppercase rounded leading-none">30 derniers jours</div>
</div>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minWidth={0}>
<AreaChart data={stats?.dailyStats || []}>
<defs>
<linearGradient id="colorClicks" x1="0" y1="0" x2="0" y2="1">

View File

@@ -1,12 +1,14 @@
"use client";
import React, { useState } from 'react';
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, Twitter, CheckCircle, AlertCircle, Hash } from 'lucide-react';
import { Business, Country } from '../../types';
import TagInput from '../TagInput';
import { generateBusinessDescription } from '../../lib/geminiService';
import { toast } from 'react-hot-toast';
import { useUser } from '../UserProvider';
import { uploadImage } from '../../app/actions/upload';
import { Loader2, Upload, Link as LinkIcon } from 'lucide-react';
// Helper to extract youtube ID
const getYouTubeId = (url: string) => {
@@ -40,6 +42,7 @@ const normalizeBusinessData = (b: Business): Business => ({
facebook: b.socialLinks?.facebook || '',
linkedin: b.socialLinks?.linkedin || '',
instagram: b.socialLinks?.instagram || '',
twitter: b.socialLinks?.twitter || '',
website: b.socialLinks?.website || '',
}
});
@@ -53,19 +56,28 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
const [isGenerating, setIsGenerating] = useState(false);
const [isMounted, setIsMounted] = useState(false);
// Fetch countries and categories
const [allTags, setAllTags] = useState<string[]>([]);
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [isUploadingCover, setIsUploadingCover] = useState(false);
const [logoInputMode, setLogoInputMode] = useState<'url' | 'upload'>('upload');
const [coverInputMode, setCoverInputMode] = useState<'url' | 'upload'>('upload');
const logoFileInputRef = React.useRef<HTMLInputElement>(null);
const coverFileInputRef = React.useRef<HTMLInputElement>(null);
// Fetch countries, categories and tags
React.useEffect(() => {
const fetchData = async () => {
try {
const [cRes, catRes] = await Promise.all([
const [cRes, catRes, tagsRes] = await Promise.all([
fetch('/api/countries'),
fetch('/api/categories')
fetch('/api/categories'),
fetch('/api/tags')
]);
const cData = await cRes.json();
const catData = await catRes.json();
const tagsData = await tagsRes.json();
if (!cData.error) setCountries(cData);
if (!catData.error) setCategories(catData);
if (!tagsData.error) setAllTags(tagsData);
} catch (error) {
console.error('Error fetching metadata:', error);
}
@@ -109,6 +121,74 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
setIsGenerating(false);
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image");
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error("L'image ne doit pas dépasser 5 Mo");
return;
}
setIsUploadingLogo(true);
const uploadFormData = new FormData();
uploadFormData.append('file', file);
try {
const result = await uploadImage(uploadFormData);
if (result.success && result.url) {
setFormData({ ...formData, logoUrl: result.url });
toast.success("Logo mis à jour !");
} else {
toast.error(result.error || "Erreur lors de l'upload");
}
} catch (error) {
toast.error("Erreur réseau lors de l'upload");
} finally {
setIsUploadingLogo(false);
if (logoFileInputRef.current) logoFileInputRef.current.value = '';
}
};
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image");
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error("L'image ne doit pas dépasser 5 Mo");
return;
}
setIsUploadingCover(true);
const uploadFormData = new FormData();
uploadFormData.append('file', file);
try {
const result = await uploadImage(uploadFormData);
if (result.success && result.url) {
setFormData({ ...formData, coverUrl: result.url });
toast.success("Bannière mise à jour !");
} else {
toast.error(result.error || "Erreur lors de l'upload");
}
} catch (error) {
toast.error("Erreur réseau lors de l'upload");
} finally {
setIsUploadingCover(false);
if (coverFileInputRef.current) coverFileInputRef.current.value = '';
}
};
const handleSave = async () => {
try {
const res = await fetch('/api/businesses', {
@@ -230,19 +310,94 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
{/* A. Bloc Identité Visuelle */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><ImageIcon className="w-5 h-5 mr-2 text-brand-600" /> Identité Visuelle</h3>
<div className="flex items-center space-x-6">
<div className="flex flex-col md:flex-row md:items-center gap-6">
<div className="shrink-0 flex flex-col items-center gap-2">
<img className="h-24 w-24 object-cover rounded-full border-2 border-gray-200" src={formData.logoUrl} alt="Logo actuel" />
<span className="text-[10px] font-bold text-gray-400 uppercase">Logo</span>
<img
className="h-24 w-24 object-cover rounded-full border-2 border-brand-200 shadow-sm"
src={formData.logoUrl || "https://picsum.photos/200/200?random=logo"}
alt="Logo actuel"
/>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Aperçu Logo</span>
</div>
<div className="flex-1 border-2 border-dashed border-gray-300 rounded-md p-6 flex flex-col items-center justify-center hover:border-brand-400 transition-colors cursor-pointer bg-gray-50">
<ImageIcon className="h-8 w-8 text-gray-400" />
<p className="mt-1 text-xs text-gray-500">Modifier le logo</p>
<div className="flex-1 space-y-4">
<div className="flex bg-gray-100 p-1 rounded-lg w-fit border border-gray-200">
<button
onClick={() => setLogoInputMode('upload')}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all flex items-center gap-2 ${logoInputMode === 'upload' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
>
<Upload className="w-3.5 h-3.5" /> Upload
</button>
<button
onClick={() => setLogoInputMode('url')}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all flex items-center gap-2 ${logoInputMode === 'url' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
>
<LinkIcon className="w-3.5 h-3.5" /> Lien URL
</button>
</div>
{logoInputMode === 'upload' ? (
<div
onClick={() => logoFileInputRef.current?.click()}
className="group border-2 border-dashed border-gray-300 rounded-xl p-6 flex flex-col items-center justify-center hover:border-brand-400 hover:bg-brand-50 transition-all cursor-pointer relative overflow-hidden"
>
{isUploadingLogo ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="w-8 h-8 text-brand-600 animate-spin" />
<span className="text-xs font-bold text-brand-600">Upload en cours...</span>
</div>
) : (
<>
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center group-hover:bg-brand-100 group-hover:text-brand-600 transition-colors mb-2">
<Upload className="w-5 h-5 text-gray-400 group-hover:text-brand-600" />
</div>
<p className="text-sm font-bold text-gray-700">Cliquez pour changer le logo</p>
<p className="text-[10px] text-gray-400 mt-1">PNG, JPG ou WEBP (Max 5Mo)</p>
</>
)}
<input
type="file"
ref={logoFileInputRef}
onChange={handleLogoUpload}
accept="image/*"
className="hidden"
/>
</div>
) : (
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
name="logoUrl"
value={formData.logoUrl || ''}
onChange={handleInputChange}
placeholder="URL de votre logo (ex: https://...)"
className="block w-full border border-gray-300 rounded-lg py-2.5 pl-10 pr-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm shadow-sm"
/>
</div>
)}
</div>
</div>
<div className="mt-8">
<label className="block text-sm font-medium text-gray-700 mb-2">Photo de Couverture (Bannière)</label>
<div className="flex items-center justify-between mb-4">
<label className="block text-sm font-medium text-gray-700">Photo de Couverture (Bannière)</label>
<div className="flex bg-gray-100 p-1 rounded-lg border border-gray-200">
<button
onClick={() => setCoverInputMode('upload')}
className={`px-3 py-1 rounded-md text-[10px] font-bold transition-all flex items-center gap-1.5 ${coverInputMode === 'upload' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
>
<Upload className="w-3 h-3" /> Upload
</button>
<button
onClick={() => setCoverInputMode('url')}
className={`px-3 py-1 rounded-md text-[10px] font-bold transition-all flex items-center gap-1.5 ${coverInputMode === 'url' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
>
<LinkIcon className="w-3 h-3" /> URL
</button>
</div>
</div>
<div className="relative h-48 w-full rounded-lg overflow-hidden bg-gray-100 border-2 border-dashed border-gray-300 group transition-all">
{formData.coverUrl ? (
<img
@@ -260,12 +415,33 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
<span className="text-xs">Aucune bannière personnalisée</span>
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button className="bg-white text-gray-900 px-4 py-1.5 rounded-full text-xs font-bold shadow-lg">Changer la photo</button>
{isUploadingCover && (
<div className="absolute inset-0 bg-white/60 backdrop-blur-sm flex flex-col items-center justify-center gap-2 z-20">
<Loader2 className="w-8 h-8 text-brand-600 animate-spin" />
<span className="text-xs font-bold text-brand-600">Upload en cours...</span>
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-10">
<button
onClick={() => coverFileInputRef.current?.click()}
className="bg-white text-gray-900 px-4 py-1.5 rounded-full text-xs font-bold shadow-lg flex items-center gap-2 hover:scale-105 transition-transform"
>
<Upload className="w-3.5 h-3.5" /> Changer la photo
</button>
</div>
<input
type="file"
ref={coverFileInputRef}
onChange={handleCoverUpload}
accept="image/*"
className="hidden"
/>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg border border-gray-200">
{/* ... (Zoom/Position controls stay same) */}
<div>
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">Zoom : {(formData.coverZoom || 1).toFixed(1)}x</label>
<input
@@ -304,14 +480,16 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
</div>
</div>
{coverInputMode === 'url' && (
<input
type="text"
name="coverUrl"
value={formData.coverUrl || ''}
onChange={handleInputChange}
placeholder="URL de votre image de couverture (ex: https://...)"
className="mt-4 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
className="mt-4 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm animate-in fade-in slide-in-from-top-1"
/>
)}
<p className="mt-1 text-[10px] text-gray-500 italic">Ajustez le zoom et la position pour un rendu optimal sur votre fiche.</p>
</div>
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6 mt-6">
@@ -381,6 +559,17 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
</div>
<textarea name="description" rows={3} value={formData.description} onChange={handleInputChange} className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm" />
</div>
<div className="sm:col-span-6">
<TagInput
value={formData.tags || []}
onChange={(tags) => setFormData({ ...formData, tags })}
suggestions={allTags}
onlySuggestions={true}
label="Mots-clés & Tags"
placeholder="Choisir dans la liste..."
/>
<p className="mt-1 text-[10px] text-gray-500 italic">Ces tags aideront les clients à trouver votre boutique via la recherche.</p>
</div>
</div>
</div>
@@ -533,6 +722,12 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
</span>
<input type="text" placeholder="Lien Instagram" value={formData.socialLinks?.instagram || ''} onChange={(e) => handleSocialChange('instagram', e.target.value)} className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md focus:ring-brand-500 focus:border-brand-500 sm:text-sm border-gray-300" />
</div>
<div className="flex rounded-md shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500">
<Twitter className="w-4 h-4" />
</span>
<input type="text" placeholder="Lien Twitter (X)" value={formData.socialLinks?.twitter || ''} onChange={(e) => handleSocialChange('twitter', e.target.value)} className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md focus:ring-brand-500 focus:border-brand-500 sm:text-sm border-gray-300" />
</div>
</div>
</div>
</div>

View File

@@ -29,7 +29,7 @@ services:
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://admin:adminpassword@db:5432/afropreunariat_dev?schema=public
- DATABASE_URL=postgresql://admin:adminpassword@db:5432/afrohub_dev?schema=public
- NEXT_PUBLIC_SITE_URL=http://localhost:3000
depends_on:
db:
@@ -45,7 +45,7 @@ services:
ports:
- "3001:3001"
environment:
- DATABASE_URL=postgresql://admin:adminpassword@db:5432/afropreunariat_dev?schema=public
- DATABASE_URL=postgresql://admin:adminpassword@db:5432/afrohub_dev?schema=public
- NEXT_PUBLIC_SITE_URL=http://localhost:3000
- NEXT_PUBLIC_ADMIN_URL=http://localhost:3001
depends_on:

View File

@@ -77,3 +77,9 @@ npx prisma studio
- **Offer** : Produits ou Services liés à un `Business`.
- **BlogPost** : Les articles du blog.
- **Interview** : Les vidéos et articles d'interviews.
```bash
prisma generate // Regénère le client Prisma
prisma migrate dev // Crée/Met à jour les tables dans la BDD
prisma studio // Ouvre l'interface web de gestion des données
```

42
lib/auth-utils.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NextRequest } from 'next/server';
import prisma from '@/lib/prisma';
export const USER_SAFE_SELECT = {
id: true,
name: true,
email: true,
role: true,
avatar: true,
bio: true,
location: true,
phone: true,
createdAt: true,
updatedAt: true,
emailVerified: true,
};
export async function getAuthUser(request: NextRequest) {
const userId = request.headers.get('x-user-id');
if (!userId) return null;
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
role: true,
emailVerified: true,
isSuspended: true
}
});
return user;
} catch (error) {
console.error('Error fetching auth user:', error);
return null;
}
}
export async function isAdmin(request: NextRequest) {
const user = await getAuthUser(request);
return user?.role === 'ADMIN';
}

47
lib/csrf.ts Normal file
View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import crypto from 'crypto';
const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'x-csrf-token';
export async function generateCsrfToken() {
const token = crypto.randomBytes(32).toString('hex');
const cookieStore = await cookies();
cookieStore.set(CSRF_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
return token;
}
export async function verifyCsrfToken(request: NextRequest) {
const cookieStore = await cookies();
const cookieToken = cookieStore.get(CSRF_COOKIE_NAME)?.value;
const headerToken = request.headers.get(CSRF_HEADER_NAME);
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return false;
}
return true;
}
/**
* Basic Origin check as an additional layer of defense
*/
export function verifyOrigin(request: NextRequest) {
const origin = request.headers.get('origin');
const host = request.headers.get('host');
if (!origin || !host) return false;
// Remove protocol from origin for comparison if host doesn't have it
const originHost = origin.replace(/^https?:\/\//, '');
return originHost === host || originHost.startsWith(host + ':');
}

38
lib/mail.ts Normal file
View File

@@ -0,0 +1,38 @@
import nodemailer from 'nodemailer';
console.log('SMTP Config:', {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: process.env.SMTP_SECURE,
user: process.env.SMTP_USER
});
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
tls: {
rejectUnauthorized: false
}
});
export const sendEmail = async ({ to, subject, html }: { to: string; subject: string; html: string }) => {
const mailOptions = {
from: process.env.SMTP_FROM || '"Afrohub" <noreply@afrohub.com>',
to,
subject,
html,
};
try {
const info = await transporter.sendMail(mailOptions);
console.log('Email sent: ' + info.response);
return info;
} catch (error) {
console.error('Error sending email:', error);
throw error;
}
};

Some files were not shown because too many files have changed in this diff Show More