Compare commits

...

75 Commits

Author SHA1 Message Date
7db35361d9 fix: update routes type definition path in next-env.d.ts 2026-05-10 18:28:44 +02:00
b6f828357d fix: update Next.js type reference path and exclude scratch directory from TypeScript configuration 2026-05-10 17:52:41 +02:00
9846efd03e feat: implement comprehensive CMS dashboard for managing news, events, interviews, and one-shot pricing plans 2026-05-10 17:50:56 +02:00
87b13dce13 feat: implement manageable homepage banner slider with support for businesses and custom slides 2026-05-10 16:30:19 +02:00
13b1730f6f feat: add related articles section based on tags or most recent posts 2026-05-10 15:15:04 +02:00
5176e51e21 UI: simplify blog footer to a sober and elegant version 2026-05-10 15:13:52 +02:00
f4f303b703 UI: replace share icon footer with a modern CTA section to explore more articles 2026-05-10 15:13:18 +02:00
7174736680 UI: fix sticky positioning for share buttons by removing sticky-killing overflow and ensuring parent stretches 2026-05-10 14:38:42 +02:00
889d3967d2 UI: force vertical sidebar on all devices and ensure it perfectly tracks the article height 2026-05-10 14:37:30 +02:00
94de36e937 UI: make floating share buttons stop at the end of the article 2026-05-10 14:33:38 +02:00
15700036e5 feat: add floating social share buttons and restore text justification 2026-05-10 14:30:38 +02:00
fe7acfa927 UI: aggressive hyphenation disable and left alignment for all text 2026-05-10 13:57:43 +02:00
c6c6a05c47 UI: refine typography, restore justification and prevent word cutting 2026-05-10 13:56:28 +02:00
f64b8ba53c UI: simplify article layout and fix text wrapping 2026-05-10 13:54:50 +02:00
3567e5d224 chore: update next-env.d.ts after successful build 2026-05-10 10:31:28 +02:00
863e8fdc4a Fix: merge cross-platform compatibility adjustments for develop branch 2026-05-10 10:30:37 +02:00
cd862e503d Merge branch 'mac' into develop 2026-05-10 10:29:52 +02:00
343f29459b maj pack sleep package 2026-05-10 09:20:47 +02:00
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
3c58e60b33 feat: add Event interface, implement core Prisma seeding, and create admin categories management page
All checks were successful
Build and Push App / build (push) Successful in 11m4s
2026-04-24 14:39:01 +02:00
11678e0b33 feat: add seeding script for pricing plans in database 2026-04-23 15:00:49 +02:00
1a66c46389 feat: add admin interface to manage and edit pricing plans
Some checks failed
Build and Push App / build (push) Failing after 46s
2026-04-23 14:50:33 +02:00
158 changed files with 11055 additions and 5826 deletions

7
.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"
# 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;

768
admin/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,9 @@
"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",
@@ -19,7 +19,7 @@
"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,117 +5,128 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
name String
email String @unique
password String
role UserRole @default(VISITOR)
avatar String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bio String?
location String?
countryId String?
phone String?
isSuspended Boolean @default(false)
suspensionReason String?
businesses Business?
comments Comment[]
conversations ConversationParticipant[]
sentMessages Message[]
reports MessageReport[]
ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
id String @id @default(uuid())
name String
email String @unique
password String
role UserRole @default(VISITOR)
avatar String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bio String?
location 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[]
favorites Favorite[]
sentMessages Message[]
reports MessageReport[]
ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
}
model Business {
id String @id @default(uuid())
name String
category String
id String @id @default(uuid())
name String
category String
location String
description String
logoUrl String
videoUrl String?
contactEmail String
contactPhone String?
socialLinks Json?
verified Boolean @default(false)
viewCount Int @default(0)
rating Float @default(0.0)
ratingCount Int @default(0)
tags String[]
isFeatured Boolean @default(false)
founderName String?
founderImageUrl String?
keyMetric String?
ownerId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slug String? @unique
isActive Boolean @default(false)
showEmail Boolean @default(true)
showPhone Boolean @default(true)
showSocials Boolean @default(true)
websiteUrl String?
isSuspended Boolean @default(false)
suspensionReason String?
plan Plan @default(STARTER)
city String?
countryId String?
coverUrl String?
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
suggestedCategory String?
location String // Legacy location string
countryId String?
city String?
description String
logoUrl String
coverUrl String?
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
videoUrl String?
contactEmail String
contactPhone String?
socialLinks Json?
verified Boolean @default(false)
viewCount Int @default(0)
rating Float @default(0.0)
ratingCount Int @default(0)
tags String[]
isFeatured Boolean @default(false)
founderName String?
founderImageUrl String?
keyMetric String?
ownerId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slug String? @unique
isActive Boolean @default(false)
showEmail Boolean @default(true)
showPhone Boolean @default(true)
showSocials Boolean @default(true)
websiteUrl String?
isSuspended Boolean @default(false)
suspensionReason String?
plan Plan @default(STARTER)
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?
categoryId String?
metaDescription String?
metaTitle 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[]
favorites Favorite[]
homeSlides HomeSlide[]
offers Offer[]
ratings Rating[]
}
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
isActive Boolean @default(true)
businesses Business[]
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
name String @unique
code String @unique
flag String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
description String?
businesses Business[]
users User[]
}
model PricingPlan {
id String @id @default(uuid())
tier Plan @unique
name String
priceXOF String
yearlyPriceXOF String?
priceEUR String
id String @id @default(uuid())
tier Plan @unique
name String
priceXOF String
priceEUR String
description String
features String[]
offerLimit Int @default(1)
recommended Boolean @default(false)
color String @default("gray")
updatedAt DateTime @updatedAt
yearlyPriceEUR String?
description String
features String[]
offerLimit Int @default(1)
recommended Boolean @default(false)
color String @default("gray")
updatedAt DateTime @updatedAt
yearlyPriceXOF String?
}
model Offer {
@@ -134,25 +145,41 @@ model Offer {
}
model BlogPost {
id String @id @default(uuid())
id String @id @default(uuid())
title String
slug String? @unique
excerpt String
content String
author String
date DateTime @default(now())
date DateTime @default(now())
imageUrl String
tags String[] @default([])
metaTitle String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metaTitle String?
slug String? @unique
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
}
model HomeSlide {
id String @id @default(uuid())
type HomeSlideType @default(CUSTOM)
businessId String?
title String?
subtitle String?
imageUrl String?
linkUrl String?
order Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
business Business? @relation(fields: [businessId], references: [id])
}
model Interview {
id String @id @default(uuid())
title String
slug String? @unique
guestName String
companyName String
role String
@@ -163,11 +190,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 +218,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())
id String @id @default(uuid())
value Int
userId String
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
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 +290,105 @@ 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 OneShotPlan {
id String @id @default(uuid())
type OneShotType @unique
name String
priceXOF Int @default(0)
priceEUR Int @default(0)
description String?
updatedAt DateTime @updatedAt
}
enum OneShotType {
BUMP
POST_EVENT
POST_NEWS
AD_DIRECTORY
}
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())
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, businessId])
}
enum HomeSlideType {
BUSINESS
CUSTOM
}
enum ContentStatus {
DRAFT
PENDING
PUBLISHED
ARCHIVED
}
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
enum ReportStatus {
PENDING
RESOLVED
@@ -293,50 +416,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,13 @@ 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 };
revalidatePath("/actualites");
revalidatePath("/");
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 +49,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);
@@ -55,8 +63,9 @@ export async function updateBlogPost(id: string, data: {
tags: data.tags || [],
},
});
revalidatePath("/blog");
return { success: true, data: post };
revalidatePath("/actualites");
revalidatePath("/");
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" };
@@ -68,7 +77,8 @@ export async function deleteBlogPost(id: string) {
await prisma.blogPost.delete({
where: { id },
});
revalidatePath("/blog");
revalidatePath("/actualites");
revalidatePath("/");
return { success: true };
} catch (error) {
console.error("Failed to delete blog post:", error);

View File

@@ -0,0 +1,64 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { ContentStatus } from "@prisma/client";
export async function approveBlogPost(id: string) {
try {
await prisma.blogPost.update({
where: { id },
data: { status: ContentStatus.PUBLISHED }
});
revalidatePath("/actualites");
revalidatePath("/blog");
return { success: true };
} catch (error) {
console.error("Failed to approve blog post:", error);
return { success: false, error: "Erreur lors de l'approbation" };
}
}
export async function approveEvent(id: string) {
try {
await prisma.event.update({
where: { id },
data: { status: ContentStatus.PUBLISHED }
});
revalidatePath("/actualites");
revalidatePath("/evenements");
return { success: true };
} catch (error) {
console.error("Failed to approve event:", error);
return { success: false, error: "Erreur lors de l'approbation" };
}
}
export async function rejectBlogPost(id: string) {
try {
// We could delete or archive it. Let's archive it.
await prisma.blogPost.update({
where: { id },
data: { status: ContentStatus.ARCHIVED }
});
revalidatePath("/actualites");
return { success: true };
} catch (error) {
console.error("Failed to reject blog post:", error);
return { success: false, error: "Erreur lors du rejet" };
}
}
export async function rejectEvent(id: string) {
try {
await prisma.event.update({
where: { id },
data: { status: ContentStatus.ARCHIVED }
});
revalidatePath("/actualites");
return { success: true };
} catch (error) {
console.error("Failed to reject event:", error);
return { success: false, error: "Erreur lors du rejet" };
}
}

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

@@ -0,0 +1,40 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { OneShotType } from "@prisma/client";
export async function getOneShotPlans() {
try {
return await prisma.oneShotPlan.findMany({
orderBy: { type: 'asc' }
});
} catch (error) {
console.error("Failed to fetch one-shot plans:", error);
return [];
}
}
export async function updateOneShotPlan(type: OneShotType, data: {
name: string;
priceXOF: number;
priceEUR: number;
description?: string;
}) {
try {
await prisma.oneShotPlan.upsert({
where: { type },
update: data,
create: {
type,
...data
}
});
revalidatePath("/settings");
revalidatePath("/subscription");
return { success: true };
} catch (error) {
console.error("Failed to update one-shot plan:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}

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,114 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function getSlides() {
try {
return await prisma.homeSlide.findMany({
orderBy: { order: 'asc' },
include: { business: true }
});
} catch (error) {
console.error("Failed to get slides:", error);
return [];
}
}
export async function createSlide(data: any) {
try {
await prisma.homeSlide.create({
data: {
type: data.type,
businessId: data.businessId || null,
title: data.title || null,
subtitle: data.subtitle || null,
imageUrl: data.imageUrl || null,
linkUrl: data.linkUrl || null,
order: data.order || 0,
isActive: data.isActive ?? true,
}
});
revalidatePath("/slides");
return { success: true };
} catch (error) {
console.error("Failed to create slide:", error);
return { success: false, error: "Erreur lors de la création" };
}
}
export async function updateSlide(id: string, data: any) {
try {
await prisma.homeSlide.update({
where: { id },
data: {
type: data.type,
businessId: data.businessId || null,
title: data.title || null,
subtitle: data.subtitle || null,
imageUrl: data.imageUrl || null,
linkUrl: data.linkUrl || null,
order: data.order || 0,
isActive: data.isActive ?? true,
}
});
revalidatePath("/slides");
return { success: true };
} catch (error) {
console.error("Failed to update slide:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteSlide(id: string) {
try {
await prisma.homeSlide.delete({
where: { id }
});
revalidatePath("/slides");
return { success: true };
} catch (error) {
console.error("Failed to delete slide:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}
export async function toggleSlideStatus(id: string, currentStatus: boolean) {
try {
await prisma.homeSlide.update({
where: { id },
data: { isActive: !currentStatus }
});
revalidatePath("/slides");
return { success: true };
} catch (error) {
console.error("Failed to toggle slide status:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function searchBusinesses(query: string) {
try {
return await prisma.business.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ location: { contains: query, mode: 'insensitive' } },
],
isActive: true,
},
take: 10,
select: {
id: true,
name: true,
logoUrl: true,
coverUrl: true,
description: true,
location: true,
}
});
} catch (error) {
console.error("Failed to search businesses:", error);
return [];
}
}

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

@@ -1,8 +1,8 @@
import { prisma } from "@/lib/prisma";
import BlogForm from "@/components/BlogForm";
import ActualitesForm from "@/components/ActualitesForm";
import { notFound } from "next/navigation";
export default async function EditBlogPage({ params }: { params: Promise<{ id: string }> }) {
export default async function EditActualitesPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await prisma.blogPost.findUnique({
where: { id },
@@ -12,5 +12,5 @@ export default async function EditBlogPage({ params }: { params: Promise<{ id: s
notFound();
}
return <BlogForm initialData={post} />;
return <ActualitesForm initialData={post} />;
}

View File

@@ -0,0 +1,5 @@
import ActualitesForm from "@/components/ActualitesForm";
export default function NewActualitesPage() {
return <ActualitesForm />;
}

View File

@@ -1,9 +1,10 @@
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin } from 'lucide-react';
import DeleteBlogButton from '@/components/DeleteBlogButton';
import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin, Clock, ShieldCheck, ShieldAlert } from 'lucide-react';
import DeleteActualitesButton from '@/components/DeleteActualitesButton';
import DeleteInterviewButton from '@/components/DeleteInterviewButton';
import DeleteEventButton from '@/components/DeleteEventButton';
import ApproveContentButton from '@/components/ApproveContentButton';
async function getData() {
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
@@ -12,18 +13,32 @@ async function getData() {
return { posts, interviews, events };
}
export default async function BlogCMSPage() {
const getStatusBadge = (status: string) => {
switch (status) {
case 'PENDING': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-500 border border-amber-500/20 flex items-center gap-1 w-fit"><Clock className="w-3 h-3" /> EN ATTENTE</span>;
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-red-500/10 text-red-400 border border-red-500/20 text-xs tracking-tighter">REJETÉ/ARCHIVÉ</span>;
default: return null;
}
}
export default async function ActualitesCMSPage() {
const { posts, interviews, events } = await getData();
const pendingPosts = posts.filter(p => p.status === 'PENDING');
const pendingEvents = events.filter(e => e.status === 'PENDING');
const hasPending = pendingPosts.length > 0 || pendingEvents.length > 0;
return (
<div className="space-y-8">
<div className="flex justify-between items-end mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">CMS Blog, Interviews & Événements</h1>
<h1 className="text-3xl font-bold text-white mb-2">CMS Actualités, Interviews & Événements</h1>
<p className="text-slate-400">Gérez le contenu éditorial et événementiel de la plateforme.</p>
</div>
<div className="flex flex-wrap gap-4">
<Link href="/blog/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
<Link href="/actualites/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
<Plus className="w-4 h-4" />
Nouvel Article
</Link>
@@ -38,18 +53,84 @@ export default async function BlogCMSPage() {
</div>
</div>
{/* PENDING SUBMISSIONS SECTION */}
{hasPending && (
<div className="bg-amber-500/5 border border-amber-500/20 rounded-2xl p-6 mb-8 animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-amber-500/20 rounded-xl text-amber-500">
<ShieldAlert className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Soumissions en attente ({pendingPosts.length + pendingEvents.length})</h2>
<p className="text-sm text-slate-400">Contenu soumis par les utilisateurs nécessitant une validation.</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4">
{pendingPosts.map(post => (
<div key={post.id} className="bg-slate-900/50 border border-slate-800 rounded-xl p-4 flex items-center justify-between group hover:border-amber-500/30 transition-all">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-indigo-500/10 rounded-lg flex items-center justify-center text-indigo-400">
<BookOpen className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-white">{post.title}</h4>
<span className="text-[10px] bg-indigo-500/10 text-indigo-400 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter border border-indigo-500/20">Article</span>
</div>
<p className="text-xs text-slate-500 mt-0.5">Par {post.author} {new Date(post.createdAt).toLocaleDateString('fr-FR')}</p>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Link href={`/actualites/edit/${post.id}`} className="p-2 text-slate-400 hover:text-white" title="Modifier/Voir">
<Edit className="w-5 h-5" />
</Link>
<ApproveContentButton id={post.id} type="post" action="approve" />
<ApproveContentButton id={post.id} type="post" action="reject" />
</div>
</div>
))}
{pendingEvents.map(event => (
<div key={event.id} className="bg-slate-900/50 border border-slate-800 rounded-xl p-4 flex items-center justify-between group hover:border-amber-500/30 transition-all">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-amber-500/10 rounded-lg flex items-center justify-center text-amber-500">
<Calendar className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-white">{event.title}</h4>
<span className="text-[10px] bg-amber-500/10 text-amber-400 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter border border-amber-500/20">Événement</span>
</div>
<p className="text-xs text-slate-500 mt-0.5">{new Date(event.date).toLocaleDateString('fr-FR')} {event.location}</p>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Link href={`/event/edit/${event.id}`} className="p-2 text-slate-400 hover:text-white" title="Modifier/Voir">
<Edit className="w-5 h-5" />
</Link>
<ApproveContentButton id={event.id} type="event" action="approve" />
<ApproveContentButton id={event.id} type="event" action="reject" />
</div>
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 gap-8">
{/* Blog Posts Section */}
<div className="card">
<div className="flex items-center gap-2 mb-6">
<BookOpen className="text-indigo-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Articles de Blog ({posts.length})</h2>
<h2 className="text-xl font-bold text-white">Articles d'Actualités ({posts.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="data-table w-full">
<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,14 +143,15 @@ 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">
<div className="flex justify-end gap-2">
<Link href={`/blog/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
<Link href={`/actualites/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
<Edit className="w-5 h-5" />
</Link>
<DeleteBlogButton id={post.id} />
<DeleteActualitesButton id={post.id} />
</div>
</td>
</tr>
@@ -90,6 +172,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 +185,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 +221,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 +235,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,5 +0,0 @@
import BlogForm from "@/components/BlogForm";
export default function NewBlogPage() {
return <BlogForm />;
}

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}
>
{typeof item.icon === 'function' ? item.icon() : <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,26 +100,34 @@ 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">
<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" />
<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" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Code ISO (2 lettres)</label>
<input name="code" required maxLength={2} placeholder="Ex: SN" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 uppercase" />
</div>
<div className="space-y-2">
<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>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Code ISO (2 lettres)</label>
<input name="code" required maxLength={2} placeholder="Ex: SN" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 uppercase" />
<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="space-y-2">
<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 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

@@ -2,20 +2,32 @@
import React, { useState, useEffect } from 'react';
import { getPricingPlans } from '@/app/actions/plans';
import { getOneShotPlans } from '@/app/actions/one-shot';
import { CreditCard, Edit3, CheckCircle2, Star, Loader2, Zap } from 'lucide-react';
import PlanEditModal from '@/components/PlanEditModal';
import OneShotPricingSettings from '@/components/OneShotPricingSettings';
export default function PlansPage() {
const [plans, setPlans] = useState<any[]>([]);
const [oneShotPlans, setOneShotPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editingPlan, setEditingPlan] = useState<any>(null);
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
useEffect(() => {
async function loadPlans() {
const data = await getPricingPlans();
setPlans(data);
setLoading(false);
try {
const [plansData, oneShotData] = await Promise.all([
getPricingPlans(),
getOneShotPlans()
]);
setPlans(plansData);
setOneShotPlans(oneShotData || []);
} catch (error) {
console.error("Failed to load plans:", error);
} finally {
setLoading(false);
}
}
loadPlans();
}, []);
@@ -32,8 +44,8 @@ export default function PlansPage() {
<div className="p-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Tarifs & Forfaits</h1>
<p className="text-slate-400">Modifiez les noms, prix et limites d'offres affichés sur le site.</p>
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Forfaits & Services</h1>
<p className="text-slate-400">Modifiez les abonnements mensuels et les services ponctuels (One-Shot).</p>
</div>
{/* Toggle Billing */}
@@ -126,8 +138,14 @@ export default function PlansPage() {
isOpen={!!editingPlan}
onClose={() => setEditingPlan(null)}
plan={editingPlan}
currentCycle={billingCycle}
/>
)}
{/* One Shot Plans Section */}
<div className="mt-16 pt-16 border-t border-slate-800">
<OneShotPricingSettings initialPlans={oneShotPlans} />
</div>
</div>
);
}

View File

@@ -4,7 +4,8 @@ import React, { useState, useEffect, useTransition } from 'react';
import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase } from 'lucide-react';
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
import { toast } from 'react-hot-toast';
import { getBlogPosts } from '@/app/actions/blog';
import { getBlogPosts } from '@/app/actions/actualites';
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,19 +46,33 @@ 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() {
const [settingsData, postsData] = await Promise.all([
getSiteSettings(),
getBlogPosts()
]);
setSettings(settingsData);
setAllPosts(postsData);
setSelectedPostIds(settingsData?.homeBlogIds || []);
setSelectedHomeCategories(settingsData?.homeCategories || []);
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
setLoading(false);
try {
const [settingsData, postsData] = await Promise.all([
getSiteSettings(),
getBlogPosts()
]);
setSettings(settingsData);
setAllPosts(postsData);
setSelectedPostIds(settingsData?.homeBlogIds || []);
setSelectedHomeCategories(settingsData?.homeCategories || []);
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || '');
setVerifyEmailTemplate(settingsData?.verifyEmailTemplate || '');
} catch (error) {
console.error("Failed to load settings data:", error);
toast.error("Erreur lors du chargement des données");
} finally {
setLoading(false);
}
}
loadData();
}, []);
@@ -80,6 +99,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 () => {
@@ -93,19 +116,19 @@ export default function SettingsPage() {
};
const togglePostId = (id: string) => {
setSelectedPostIds(prev =>
setSelectedPostIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
const toggleHomeCategory = (cat: string) => {
setSelectedHomeCategories(prev =>
setSelectedHomeCategories(prev =>
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
);
};
const toggleBlogCategory = (cat: string) => {
setSelectedBlogCategories(prev =>
setSelectedBlogCategories(prev =>
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
);
};
@@ -123,178 +146,83 @@ 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">
{/* 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">
<Globe className="w-5 h-5 text-indigo-400" />
<h2 className="font-semibold text-white">Informations Générales</h2>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du site</label>
<input
name="siteName"
defaultValue={settings?.siteName}
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">Slogan du site</label>
<input
name="siteSlogan"
defaultValue={settings?.siteSlogan}
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="md:col-span-2 space-y-2">
<label className="text-sm font-medium text-slate-400">Texte du pied de page (Footer)</label>
<input
name="footerText"
defaultValue={settings?.footerText}
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>
</div>
{/* Section Page d'Accueil - Secteurs */}
<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">
<Briefcase className="w-5 h-5 text-yellow-400" />
<h2 className="font-semibold text-white">Secteurs en Vedette (Home)</h2>
</div>
<div className="p-6">
<p className="text-sm text-slate-400 mb-4">Sélectionnez les secteurs d'activité à afficher sur la page d'accueil.</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{BUSINESS_CATEGORIES.map(cat => (
<button
key={cat}
type="button"
onClick={() => toggleHomeCategory(cat)}
className={`text-left p-2 rounded-lg border text-xs transition-all ${
selectedHomeCategories.includes(cat)
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
{cat}
</button>
))}
</div>
</div>
</div>
{/* Section Page d'Accueil - Blog */}
<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">
<Newspaper className="w-5 h-5 text-purple-400" />
<h2 className="font-semibold text-white">Section Blog sur l'Accueil</h2>
</div>
<div className="p-6 space-y-6">
<div className="flex items-center gap-3 p-3 bg-indigo-600/10 border border-indigo-500/20 rounded-xl">
<input
type="checkbox"
name="homeBlogShow"
id="homeBlogShow"
defaultChecked={settings?.homeBlogShow}
className="w-5 h-5 rounded border-slate-700 bg-slate-950 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="homeBlogShow" className="text-sm font-medium text-slate-300 font-bold uppercase tracking-wider">Activer cette section</label>
</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">Titre de la section</label>
<input
name="homeBlogTitle"
defaultValue={settings?.homeBlogTitle}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
{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">
<Globe className="w-5 h-5 text-indigo-400" />
<h2 className="font-semibold text-white">Informations Générales</h2>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
<input
name="homeBlogSubtitle"
defaultValue={settings?.homeBlogSubtitle}
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>
<div className="border-t border-slate-700 pt-6">
<label className="text-sm font-medium text-slate-400 block mb-3">Mode de sélection des articles</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'latest' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
<input type="radio" name="homeBlogSelection" value="latest" defaultChecked={settings?.homeBlogSelection === 'latest'} className="sr-only" />
<span className="text-white font-bold text-sm">Les plus récents</span>
<span className="text-xs text-slate-500 mt-1">Affiche automatiquement les X derniers articles.</span>
</label>
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'manual' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
<input type="radio" name="homeBlogSelection" value="manual" defaultChecked={settings?.homeBlogSelection === 'manual'} className="sr-only" />
<span className="text-white font-bold text-sm">Sélection manuelle</span>
<span className="text-xs text-slate-500 mt-1">Choisissez précisément les articles à mettre en avant.</span>
</label>
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'category' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
<input type="radio" name="homeBlogSelection" value="category" defaultChecked={settings?.homeBlogSelection === 'category'} className="sr-only" />
<span className="text-white font-bold text-sm">Par catégorie</span>
<span className="text-xs text-slate-500 mt-1">Affiche les articles appartenant à certaines catégories.</span>
</label>
</div>
</div>
<div className="grid grid-cols-1 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nombre maximum d'articles</label>
<input
type="number"
name="homeBlogCount"
defaultValue={settings?.homeBlogCount}
min="1" max="12"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
{/* Manual Selection UI */}
<div className="space-y-3">
<label className="text-sm font-medium text-slate-400">Articles sélectionnés (si mode manuel)</label>
<div className="grid grid-cols-1 gap-2 max-h-60 overflow-y-auto p-2 bg-slate-950 rounded-xl border border-slate-700">
{allPosts.map(post => (
<button
key={post.id}
type="button"
onClick={() => togglePostId(post.id)}
className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${
selectedPostIds.includes(post.id)
? 'bg-indigo-600/20 border-indigo-500/50 text-white'
: 'text-slate-400 hover:bg-slate-900'
}`}
>
<span>{post.title}</span>
{selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</span>}
</button>
))}
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du site</label>
<input
name="siteName"
defaultValue={settings?.siteName}
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">Slogan du site</label>
<input
name="siteSlogan"
defaultValue={settings?.siteSlogan}
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="md:col-span-2 space-y-2">
<label className="text-sm font-medium text-slate-400">Texte du pied de page (Footer)</label>
<input
name="footerText"
defaultValue={settings?.footerText}
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>
</div>
{/* Category Selection UI */}
<div className="space-y-3">
<label className="text-sm font-medium text-slate-400">Catégories d'articles (si mode catégorie)</label>
<div className="flex flex-wrap gap-2">
{BLOG_CATEGORIES.map(cat => (
{/* Section Page d'Accueil - Secteurs */}
<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">
<Briefcase className="w-5 h-5 text-yellow-400" />
<h2 className="font-semibold text-white">Secteurs en Vedette (Home)</h2>
</div>
<div className="p-6">
<p className="text-sm text-slate-400 mb-4">Sélectionnez les secteurs d'activité à afficher sur la page d'accueil.</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{BUSINESS_CATEGORIES.map(cat => (
<button
key={cat}
type="button"
onClick={() => toggleBlogCategory(cat)}
className={`px-4 py-2 rounded-full border text-xs transition-all ${
selectedBlogCategories.includes(cat)
? 'bg-purple-600 border-purple-500 text-white'
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
onClick={() => toggleHomeCategory(cat)}
className={`text-left p-2 rounded-lg border text-xs transition-all ${selectedHomeCategories.includes(cat)
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
{cat}
</button>
@@ -302,109 +230,286 @@ export default function SettingsPage() {
</div>
</div>
</div>
</div>
</div>
{/* Section Contact */}
<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-emerald-400" />
<h2 className="font-semibold text-white">Coordonnées de Contact</h2>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Email de support</label>
<div className="relative">
<Mail className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="contactEmail"
defaultValue={settings?.contactEmail}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
{/* Section Page d'Accueil - Blog */}
<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">
<Newspaper className="w-5 h-5 text-purple-400" />
<h2 className="font-semibold text-white">Section Blog sur l'Accueil</h2>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Téléphone de contact</label>
<div className="relative">
<Phone className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="contactPhone"
defaultValue={settings?.contactPhone}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-sm font-medium text-slate-400">Adresse physique</label>
<div className="relative">
<MapPin className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="address"
defaultValue={settings?.address}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div>
</div>
<div className="p-6 space-y-6">
<div className="flex items-center gap-3 p-3 bg-indigo-600/10 border border-indigo-500/20 rounded-xl">
<input
type="checkbox"
name="homeBlogShow"
id="homeBlogShow"
defaultChecked={settings?.homeBlogShow}
className="w-5 h-5 rounded border-slate-700 bg-slate-950 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="homeBlogShow" className="text-sm font-medium text-slate-300 font-bold uppercase tracking-wider">Activer cette section</label>
</div>
{/* Section Réseaux Sociaux */}
<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">
<Share2 className="w-5 h-5 text-blue-400" />
<h2 className="font-semibold text-white">Réseaux Sociaux</h2>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<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" />
<input
name="facebookUrl"
defaultValue={settings?.facebookUrl}
placeholder="https://facebook.com/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
<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">Titre de la section</label>
<input
name="homeBlogTitle"
defaultValue={settings?.homeBlogTitle}
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">Sous-titre</label>
<input
name="homeBlogSubtitle"
defaultValue={settings?.homeBlogSubtitle}
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>
<div className="border-t border-slate-700 pt-6">
<label className="text-sm font-medium text-slate-400 block mb-3">Mode de sélection des articles</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'latest' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
<input type="radio" name="homeBlogSelection" value="latest" defaultChecked={settings?.homeBlogSelection === 'latest'} className="sr-only" />
<span className="text-white font-bold text-sm">Les plus récents</span>
<span className="text-xs text-slate-500 mt-1">Affiche automatiquement les X derniers articles.</span>
</label>
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'manual' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
<input type="radio" name="homeBlogSelection" value="manual" defaultChecked={settings?.homeBlogSelection === 'manual'} className="sr-only" />
<span className="text-white font-bold text-sm">Sélection manuelle</span>
<span className="text-xs text-slate-500 mt-1">Choisissez précisément les articles à mettre en avant.</span>
</label>
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'category' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
<input type="radio" name="homeBlogSelection" value="category" defaultChecked={settings?.homeBlogSelection === 'category'} className="sr-only" />
<span className="text-white font-bold text-sm">Par catégorie</span>
<span className="text-xs text-slate-500 mt-1">Affiche les articles appartenant à certaines catégories.</span>
</label>
</div>
</div>
<div className="grid grid-cols-1 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nombre maximum d'articles</label>
<input
type="number"
name="homeBlogCount"
defaultValue={settings?.homeBlogCount}
min="1" max="12"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
{/* Manual Selection UI */}
<div className="space-y-3">
<label className="text-sm font-medium text-slate-400">Articles sélectionnés (si mode manuel)</label>
<div className="grid grid-cols-1 gap-2 max-h-60 overflow-y-auto p-2 bg-slate-950 rounded-xl border border-slate-700">
{allPosts.map(post => (
<button
key={post.id}
type="button"
onClick={() => togglePostId(post.id)}
className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${selectedPostIds.includes(post.id)
? 'bg-indigo-600/20 border-indigo-500/50 text-white'
: 'text-slate-400 hover:bg-slate-900'
}`}
>
<span>{post.title}</span>
{selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</span>}
</button>
))}
</div>
</div>
{/* Category Selection UI */}
<div className="space-y-3">
<label className="text-sm font-medium text-slate-400">Catégories d'articles (si mode catégorie)</label>
<div className="flex flex-wrap gap-2">
{BLOG_CATEGORIES.map(cat => (
<button
key={cat}
type="button"
onClick={() => toggleBlogCategory(cat)}
className={`px-4 py-2 rounded-full border text-xs transition-all ${selectedBlogCategories.includes(cat)
? 'bg-purple-600 border-purple-500 text-white'
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
{cat}
</button>
))}
</div>
</div>
</div>
</div>
</div>
<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" />
<input
name="twitterUrl"
defaultValue={settings?.twitterUrl}
placeholder="https://twitter.com/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
{/* Section Contact */}
<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-emerald-400" />
<h2 className="font-semibold text-white">Coordonnées de Contact</h2>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Email de support</label>
<div className="relative">
<Mail className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="contactEmail"
defaultValue={settings?.contactEmail}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Téléphone de contact</label>
<div className="relative">
<Phone className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="contactPhone"
defaultValue={settings?.contactPhone}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-sm font-medium text-slate-400">Adresse physique</label>
<div className="relative">
<MapPin className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="address"
defaultValue={settings?.address}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div>
</div>
<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" />
<input
name="instagramUrl"
defaultValue={settings?.instagramUrl}
placeholder="https://instagram.com/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
{/* Section Réseaux Sociaux */}
<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">
<Share2 className="w-5 h-5 text-blue-400" />
<h2 className="font-semibold text-white">Réseaux Sociaux</h2>
</div>
</div>
<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" />
<input
name="linkedinUrl"
defaultValue={settings?.linkedinUrl}
placeholder="https://linkedin.com/in/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Facebook URL</label>
<div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="facebookUrl"
defaultValue={settings?.facebookUrl}
placeholder="https://facebook.com/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
<div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="twitterUrl"
defaultValue={settings?.twitterUrl}
placeholder="https://twitter.com/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Instagram URL</label>
<div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="instagramUrl"
defaultValue={settings?.instagramUrl}
placeholder="https://instagram.com/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">LinkedIn URL</label>
<div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="linkedinUrl"
defaultValue={settings?.linkedinUrl}
placeholder="https://linkedin.com/in/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</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 +529,4 @@ export default function SettingsPage() {
</div>
);
}

View File

@@ -0,0 +1,18 @@
import SlideForm from "@/components/SlideForm";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
export default async function EditSlidePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const slide = await prisma.homeSlide.findUnique({
where: { id },
include: { business: true }
});
if (!slide) {
notFound();
}
return <SlideForm initialData={slide} />;
}

View File

@@ -0,0 +1,5 @@
import SlideForm from "@/components/SlideForm";
export default function NewSlidePage() {
return <SlideForm />;
}

View File

@@ -0,0 +1,115 @@
"use server";
import { prisma } from "@/lib/prisma";
import Link from 'next/link';
import { Plus, Image as ImageIcon, Trash2, Edit, ExternalLink, Power, PowerOff } from 'lucide-react';
import DeleteSlideButton from '@/components/DeleteSlideButton';
import ToggleSlideStatusButton from '@/components/ToggleSlideStatusButton';
async function getData() {
try {
return await prisma.homeSlide.findMany({
orderBy: { order: 'asc' },
include: { business: true }
});
} catch (error) {
console.error("Failed to fetch slides:", error);
return [];
}
}
export default async function SlidesPage() {
const slides = await getData();
return (
<div className="space-y-8">
<div className="flex justify-between items-end mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion de la Bannière</h1>
<p className="text-slate-400">Gérez les slides qui s'affichent sur la page d'accueil (Slider).</p>
</div>
<Link href="/slides/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
<Plus className="w-4 h-4" />
Nouvelle Slide
</Link>
</div>
<div className="card">
<div className="flex items-center gap-2 mb-6">
<ImageIcon className="text-indigo-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Slides Actuelles ({slides.length})</h2>
</div>
{slides.length === 0 ? (
<div className="text-center py-12 bg-slate-800/20 rounded-xl border border-dashed border-slate-700">
<p className="text-slate-500">Aucune slide configurée. Le bandeau par défaut sera affiché.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="data-table w-full">
<thead>
<tr>
<th className="text-left py-3 px-4">Ordre</th>
<th className="text-left py-3 px-4">Type</th>
<th className="text-left py-3 px-4">Contenu</th>
<th className="text-left py-3 px-4">Statut</th>
<th className="text-right py-3 px-4">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{slides.map((slide) => (
<tr key={slide.id} className="hover:bg-slate-800/30 transition-colors">
<td className="py-4 px-4">
<span className="text-white font-mono bg-slate-800 px-2 py-1 rounded">#{slide.order}</span>
</td>
<td className="py-4 px-4">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${slide.type === 'BUSINESS' ? 'bg-indigo-500/10 text-indigo-400 border border-indigo-500/20' : 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'}`}>
{slide.type === 'BUSINESS' ? 'ENTREPRISE' : 'PERSONNALISÉ'}
</span>
</td>
<td className="py-4 px-4">
<div className="flex items-center gap-3">
{slide.type === 'BUSINESS' ? (
<>
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
<img src={slide.business?.logoUrl || '/placeholder-biz.png'} className="w-full h-full object-cover" alt="" />
</div>
<div>
<div className="text-sm font-bold text-white">{slide.business?.name}</div>
<div className="text-xs text-slate-500">{slide.business?.location}</div>
</div>
</>
) : (
<>
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
<img src={slide.imageUrl || '/placeholder-img.png'} className="w-full h-full object-cover" alt="" />
</div>
<div>
<div className="text-sm font-bold text-white">{slide.title || 'Sans titre'}</div>
<div className="text-xs text-slate-500 truncate max-w-[250px]">{slide.linkUrl || 'Pas de lien'}</div>
</div>
</>
)}
</div>
</td>
<td className="py-4 px-4">
<ToggleSlideStatusButton id={slide.id} currentStatus={slide.isActive} />
</td>
<td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2">
<Link href={`/slides/edit/${slide.id}`} className="p-2 text-slate-400 hover:text-indigo-400 transition-colors">
<Edit className="w-5 h-5" />
</Link>
<DeleteSlideButton id={slide.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</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 { createBlogPost, updateBlogPost } from '@/app/actions/actualites';
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,30 +33,53 @@ 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");
router.push('/blog');
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
router.push('/actualites');
router.refresh();
} else {
toast.error(result.error || "Une erreur est survenue");
@@ -62,7 +91,7 @@ export default function BlogForm({ initialData }: Props) {
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<Link href="/actualites" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">
@@ -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,15 +141,30 @@ export default function BlogForm({ initialData }: Props) {
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de l'image</label>
<input
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 className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<ImageUploader
label="Image de couverture"
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
/>
</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">
@@ -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,61 @@
"use client";
import React, { useTransition } from 'react';
import { Check, X, Loader2 } from 'lucide-react';
import { approveBlogPost, approveEvent, rejectBlogPost, rejectEvent } from '@/app/actions/approval';
import { toast } from 'react-hot-toast';
interface ApproveContentButtonProps {
id: string;
type: 'post' | 'event';
action: 'approve' | 'reject';
}
export default function ApproveContentButton({ id, type, action }: ApproveContentButtonProps) {
const [isPending, startTransition] = useTransition();
const handleAction = async () => {
if (action === 'reject' && !confirm("Êtes-vous sûr de vouloir rejeter ce contenu ?")) {
return;
}
startTransition(async () => {
let result;
if (type === 'post') {
result = action === 'approve' ? await approveBlogPost(id) : await rejectBlogPost(id);
} else {
result = action === 'approve' ? await approveEvent(id) : await rejectEvent(id);
}
if (result.success) {
toast.success(action === 'approve' ? "Contenu approuvé et publié" : "Contenu rejeté");
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
if (action === 'approve') {
return (
<button
onClick={handleAction}
disabled={isPending}
className="p-2 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 rounded-lg transition-colors disabled:opacity-50"
title="Approuver et Publier"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Check className="w-5 h-5" />}
</button>
);
}
return (
<button
onClick={handleAction}
disabled={isPending}
className="p-2 bg-red-500/10 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors disabled:opacity-50"
title="Rejeter"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <X className="w-5 h-5" />}
</button>
);
}

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,11 +1,11 @@
"use client";
import { useTransition } from 'react';
import { deleteBlogPost } from '@/app/actions/blog';
import { deleteBlogPost } from '@/app/actions/actualites';
import { Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function DeleteBlogButton({ id }: { id: string }) {
export default function DeleteActualitesButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {

View File

@@ -0,0 +1,34 @@
"use client";
import { useTransition } from 'react';
import { deleteSlide } from '@/app/actions/slides';
import { Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function DeleteSlideButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Supprimer cette slide du slider d'accueil ?")) {
startTransition(async () => {
const result = await deleteSlide(id);
if (result.success) {
toast.success("Slide supprimée");
} else {
toast.error(result.error || "Erreur lors de la suppression");
}
});
}
};
return (
<button
onClick={handleDelete}
disabled={isPending}
className="p-2 text-slate-500 hover:text-red-400 transition-colors disabled:opacity-50"
title="Supprimer la slide"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
);
}

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)
@@ -40,7 +67,7 @@ export default function EventForm({ initialData }: Props) {
if (result.success) {
toast.success(initialData ? "Événement mis à jour" : "Événement créé avec succès");
router.push('/blog');
router.push('/actualites');
router.refresh();
} else {
toast.error(result.error || "Une erreur est survenue");
@@ -52,7 +79,7 @@ export default function EventForm({ initialData }: Props) {
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<Link href="/actualites" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">
@@ -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)
@@ -45,7 +71,7 @@ export default function InterviewForm({ initialData }: Props) {
if (result.success) {
toast.success(initialData ? "Interview mise à jour" : "Interview créée avec succès");
router.push('/blog');
router.push('/actualites');
router.refresh();
} else {
toast.error(result.error || "Une erreur est survenue");
@@ -57,7 +83,7 @@ export default function InterviewForm({ initialData }: Props) {
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<Link href="/actualites" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">
@@ -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>
<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"
/>
<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="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
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"
<ImageUploader
label="Miniature (Thumbnail)"
value={thumbnailUrl}
onChange={setThumbnailUrl}
name="thumbnailUrl"
/>
</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

@@ -0,0 +1,183 @@
"use client";
import React, { useState, useTransition } from 'react';
import { Save, Loader2, Zap, Calendar, Newspaper, Layout } from 'lucide-react';
import { updateOneShotPlan } from '@/app/actions/one-shot';
import { toast } from 'react-hot-toast';
import { OneShotType } from '@prisma/client';
interface OneShotPlan {
id: string;
type: OneShotType;
name: string;
priceXOF: number;
priceEUR: number;
description: string | null;
}
const TYPE_ICONS = {
BUMP: Zap,
POST_EVENT: Calendar,
POST_NEWS: Newspaper,
AD_DIRECTORY: Layout,
};
const TYPE_LABELS = {
BUMP: "Remonter dans l'annuaire",
POST_EVENT: "Poster un événement",
POST_NEWS: "Poster une actualité",
AD_DIRECTORY: "Publicité dans l'annuaire",
};
export default function OneShotPricingSettings({ initialPlans }: { initialPlans: OneShotPlan[] }) {
const [plans, setPlans] = useState<OneShotPlan[]>(initialPlans);
const [isPending, startTransition] = useTransition();
// Ensure all types are present even if not in DB yet
const allTypes: OneShotType[] = ['BUMP', 'POST_EVENT', 'POST_NEWS', 'AD_DIRECTORY'];
const getPlanByType = (type: OneShotType) => {
return plans.find(p => p.type === type) || {
id: '',
type,
name: TYPE_LABELS[type],
priceXOF: 0,
priceEUR: 0,
description: ''
};
};
const handleUpdate = async (type: OneShotType, data: any) => {
startTransition(async () => {
const result = await updateOneShotPlan(type, data);
if (result.success) {
toast.success(`Plan ${TYPE_LABELS[type]} mis à jour`);
} else {
toast.error(result.error || "Erreur lors de la mise à jour");
}
});
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<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">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-400" />
<h2 className="font-semibold text-white">Tarifs des Services Ponctuels (One-Shot)</h2>
</div>
<button
type="button"
onClick={async () => {
if (confirm("Initialiser tous les plans avec les valeurs par défaut ?")) {
startTransition(async () => {
for (const type of allTypes) {
await updateOneShotPlan(type, {
name: TYPE_LABELS[type],
priceXOF: 5000,
priceEUR: 8,
description: `Service de ${TYPE_LABELS[type].toLowerCase()}`
});
}
toast.success("Plans initialisés. Rechargement...");
window.location.reload();
});
}
}}
className="text-xs bg-slate-800 hover:bg-slate-700 text-slate-300 px-3 py-1 rounded-lg border border-slate-700 transition-all"
>
Initialiser par défaut
</button>
</div>
<div className="p-6 space-y-8">
<p className="text-sm text-slate-400">
Configurez les prix des services à l'acte. Ces tarifs seront affichés sur la page de tarification.
</p>
<div className="grid grid-cols-1 gap-6">
{allTypes.map((type) => {
const plan = getPlanByType(type);
const Icon = TYPE_ICONS[type];
return (
<div key={type} className="bg-slate-950/50 border border-slate-800 rounded-xl p-6 hover:border-slate-700 transition-colors">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center">
<Icon className="w-6 h-6 text-indigo-400" />
</div>
<div>
<h3 className="font-bold text-white text-lg">{TYPE_LABELS[type]}</h3>
<p className="text-xs text-slate-500 font-mono">{type}</p>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
handleUpdate(type, {
name: formData.get('name'),
priceXOF: parseInt(formData.get('priceXOF') as string || '0'),
priceEUR: parseInt(formData.get('priceEUR') as string || '0'),
description: formData.get('description'),
});
}}
className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-4"
>
<div className="space-y-1">
<label className="text-[10px] font-bold text-slate-500 uppercase">Nom affiché</label>
<input
name="name"
defaultValue={plan.name}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold text-slate-500 uppercase">Prix (XOF)</label>
<input
type="number"
name="priceXOF"
defaultValue={plan.priceXOF}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold text-slate-500 uppercase">Prix (EUR)</label>
<input
type="number"
name="priceEUR"
defaultValue={plan.priceEUR}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="flex items-end">
<button
type="submit"
disabled={isPending}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-800 text-white p-2 rounded-lg font-bold text-sm transition-all flex items-center justify-center gap-2"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Enregistrer
</button>
</div>
<div className="md:col-span-4 space-y-1">
<label className="text-[10px] font-bold text-slate-500 uppercase">Description courte</label>
<input
name="description"
defaultValue={plan.description || ''}
placeholder="Ex: Remontez votre entreprise en tête de liste pour 7 jours"
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</form>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -9,9 +9,10 @@ interface PlanEditModalProps {
isOpen: boolean;
onClose: () => void;
plan: any;
currentCycle: 'monthly' | 'yearly';
}
export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalProps) {
export default function PlanEditModal({ isOpen, onClose, plan, currentCycle }: PlanEditModalProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: plan.name,
@@ -73,7 +74,9 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/50">
<div>
<h2 className="text-xl font-bold text-white font-serif">Modifier le plan {plan.tier}</h2>
<p className="text-sm text-slate-400">Configurez l'affichage et les limites de ce forfait.</p>
<p className="text-sm text-slate-400">
Édition du mode <span className="text-indigo-400 font-bold uppercase">{currentCycle === 'yearly' ? 'Annuel' : 'Mensuel'}</span>
</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X className="w-6 h-6" />
@@ -101,40 +104,48 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
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 font-sans uppercase tracking-wider">Prix XOF (Mensuel)</label>
<input
value={formData.priceXOF}
onChange={(e) => setFormData({...formData, priceXOF: 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 transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Annuel)</label>
<input
value={formData.yearlyPriceXOF}
onChange={(e) => setFormData({...formData, yearlyPriceXOF: e.target.value})}
placeholder="ex: 45 000 FCFA"
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 font-sans uppercase tracking-wider">Prix EUR (Mensuel)</label>
<input
value={formData.priceEUR}
onChange={(e) => setFormData({...formData, priceEUR: 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 transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Annuel)</label>
<input
value={formData.yearlyPriceEUR}
onChange={(e) => setFormData({...formData, yearlyPriceEUR: e.target.value})}
placeholder="ex: 69 €"
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>
{currentCycle === 'monthly' ? (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Mensuel)</label>
<input
value={formData.priceXOF}
onChange={(e) => setFormData({...formData, priceXOF: 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 transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Mensuel)</label>
<input
value={formData.priceEUR}
onChange={(e) => setFormData({...formData, priceEUR: 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 transition-colors"
/>
</div>
</>
) : (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Annuel)</label>
<input
value={formData.yearlyPriceXOF}
onChange={(e) => setFormData({...formData, yearlyPriceXOF: e.target.value})}
placeholder="ex: 45 000 FCFA"
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 shadow-[0_0_10px_rgba(16,185,129,0.1)] border-emerald-500/30"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Annuel)</label>
<input
value={formData.yearlyPriceEUR}
onChange={(e) => setFormData({...formData, yearlyPriceEUR: e.target.value})}
placeholder="ex: 69 €"
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 shadow-[0_0_10px_rgba(16,185,129,0.1)] border-emerald-500/30"
/>
</div>
</>
)}
</div>
<div className="space-y-2">

View File

@@ -15,20 +15,22 @@ import {
Settings,
Globe,
FileText,
Briefcase
Briefcase,
Tags,
Image as ImageIcon
} 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: 'Actualités & Interviews', icon: BookOpen, href: '/actualites' },
{ name: 'Forfaits & Services', icon: CreditCard, href: '/plans' },
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
{ name: 'Pays', icon: Globe, href: '/countries' },
{ name: 'Bannière', icon: ImageIcon, href: '/slides' },
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
{ name: 'Configuration', icon: Settings, href: '/settings' },
];
@@ -67,7 +69,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,292 @@
"use client";
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createSlide, updateSlide, searchBusinesses } from '@/app/actions/slides';
import { Loader2, ArrowLeft, Save, Search, Check, X, ImageIcon, Briefcase } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import ImageUploader from './ImageUploader';
interface Props {
initialData?: any;
}
export default function SlideForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
// Form state
const [type, setType] = useState(initialData?.type || 'CUSTOM');
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
const [isActive, setIsActive] = useState(initialData?.isActive ?? true);
// Business search state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [selectedBusiness, setSelectedBusiness] = useState<any>(initialData?.business || null);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
if (searchQuery.length > 1) {
const delayDebounceFn = setTimeout(() => {
setIsSearching(true);
searchBusinesses(searchQuery).then(results => {
setSearchResults(results);
setIsSearching(false);
});
}, 300);
return () => clearTimeout(delayDebounceFn);
} else {
setSearchResults([]);
}
}, [searchQuery]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = {
type,
businessId: type === 'BUSINESS' ? selectedBusiness?.id : null,
title: formData.get('title') as string,
subtitle: formData.get('subtitle') as string,
imageUrl: type === 'CUSTOM' ? imageUrl : (selectedBusiness?.coverUrl || selectedBusiness?.logoUrl || ''),
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
order: parseInt(formData.get('order') as string) || 0,
isActive,
};
if (type === 'BUSINESS' && !data.businessId) {
toast.error("Veuillez sélectionner une entreprise");
return;
}
if (type === 'CUSTOM' && !data.imageUrl) {
toast.error("Une image est requise pour une slide personnalisée");
return;
}
startTransition(async () => {
const result = initialData
? await updateSlide(initialData.id, data)
: await createSlide(data);
if (result.success) {
toast.success(initialData ? "Slide mise à jour" : "Slide créée avec succès");
router.push('/slides');
router.refresh();
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
return (
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/slides" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">
{initialData ? 'Modifier la Slide' : 'Nouvelle Slide'}
</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6">
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Type de Slide</h2>
<div className="flex bg-slate-900 p-1 rounded-lg border border-slate-800">
<button
type="button"
onClick={() => setType('BUSINESS')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all ${
type === 'BUSINESS' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'
}`}
>
<Briefcase className="w-4 h-4" />
Entreprise
</button>
<button
type="button"
onClick={() => setType('CUSTOM')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all ${
type === 'CUSTOM' ? 'bg-emerald-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'
}`}
>
<ImageIcon className="w-4 h-4" />
Personnalisé
</button>
</div>
</div>
{type === 'BUSINESS' ? (
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
<label className="text-sm font-medium text-slate-400">Rechercher une entreprise</label>
{selectedBusiness ? (
<div className="flex items-center justify-between p-4 bg-indigo-500/10 border border-indigo-500/20 rounded-xl">
<div className="flex items-center gap-4">
<img src={selectedBusiness.logoUrl} className="w-12 h-12 rounded-lg object-cover" alt="" />
<div>
<div className="text-white font-bold">{selectedBusiness.name}</div>
<div className="text-xs text-indigo-400">{selectedBusiness.location}</div>
</div>
</div>
<button
type="button"
onClick={() => setSelectedBusiness(null)}
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
) : (
<div className="relative">
<Search className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
<input
key="search-query-input"
type="text"
value={searchQuery || ""}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Tapez le nom d'une entreprise..."
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"
/>
{isSearching && (
<div className="absolute right-3 top-3">
<Loader2 className="w-5 h-5 text-indigo-500 animate-spin" />
</div>
)}
{searchResults.length > 0 && (
<div className="absolute z-10 w-full mt-2 bg-slate-800 border border-slate-700 rounded-xl shadow-2xl overflow-hidden max-h-60 overflow-y-auto">
{searchResults.map((biz) => (
<button
key={biz.id}
type="button"
onClick={() => {
setSelectedBusiness(biz);
setSearchQuery('');
setSearchResults([]);
}}
className="w-full flex items-center gap-3 p-3 hover:bg-slate-700 text-left transition-colors border-b border-slate-700 last:border-0"
>
<img src={biz.logoUrl} className="w-10 h-10 rounded object-cover" alt="" />
<div>
<div className="text-sm font-bold text-white">{biz.name}</div>
<div className="text-xs text-slate-400">{biz.location}</div>
</div>
</button>
))}
</div>
)}
</div>
)}
<div className="p-4 bg-slate-900/50 rounded-lg border border-slate-800 italic text-xs text-slate-500">
L'image, le titre et le lien seront automatiquement récupérés depuis la fiche de l'entreprise.
</div>
</div>
) : (
<div className="space-y-6 animate-in fade-in slide-in-from-top-2 duration-300">
<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">Titre</label>
<input
key="custom-title-input"
name="title"
defaultValue={initialData?.title || ""}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
placeholder="Ex: Découvrez l'artisanat local"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
<input
key="custom-subtitle-input"
name="subtitle"
defaultValue={initialData?.subtitle || ""}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
placeholder="Ex: Des produits uniques faits main"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Lien (URL)</label>
<input
key="custom-link-input"
name="linkUrl"
defaultValue={initialData?.linkUrl || ""}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
placeholder="Ex: /annuaire ou https://..."
/>
</div>
<ImageUploader
label="Image de fond"
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
/>
</div>
)}
</div>
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Paramètres d'Affichage</h2>
<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">Ordre d'affichage</label>
<input
key="order-input"
name="order"
type="number"
defaultValue={initialData?.order ?? 0}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="0"
/>
<p className="text-xs text-slate-500">Les slides sont triées par ordre croissant.</p>
</div>
<div className="flex items-center justify-between p-4 bg-slate-900 rounded-xl border border-slate-800">
<span className="text-sm font-medium text-slate-300">Statut de la slide</span>
<button
type="button"
onClick={() => setIsActive(!isActive)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
isActive ? 'bg-emerald-600' : 'bg-slate-700'
}`}
>
<span
className={`${
isActive ? 'translate-x-6' : 'translate-x-1'
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
/>
</button>
</div>
</div>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Créer la slide'}
</button>
</div>
</form>
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { useTransition } from 'react';
import { toggleSlideStatus } from '@/app/actions/slides';
import { Power, PowerOff, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function ToggleSlideStatusButton({ id, currentStatus }: { id: string, currentStatus: boolean }) {
const [isPending, startTransition] = useTransition();
const handleToggle = () => {
startTransition(async () => {
const result = await toggleSlideStatus(id, currentStatus);
if (result.success) {
toast.success(currentStatus ? "Slide désactivée" : "Slide activée");
} else {
toast.error(result.error || "Erreur lors de la mise à jour");
}
});
};
return (
<button
onClick={handleToggle}
disabled={isPending}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
currentStatus
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/20'
: 'bg-slate-500/10 text-slate-400 border border-slate-500/20 hover:bg-slate-500/20'
} disabled:opacity-50`}
>
{isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : currentStatus ? (
<Power className="w-3.5 h-3.5" />
) : (
<PowerOff className="w-3.5 h-3.5" />
)}
<span>{currentStatus ? 'ACTIVE' : 'INACTIVE'}</span>
</button>
);
}

View File

@@ -1,6 +1,6 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
// Updated: 2026-04-18 14:28 (Forced reload for PricingPlan model)
// Updated: 2026-05-10T17:21 (Forced reload for OneShotPlan model)
import pg from 'pg'
const connectionString = process.env.DATABASE_URL!
@@ -11,10 +11,10 @@ function makePrisma() {
return new PrismaClient({ adapter })
}
const globalForPrisma = globalThis as unknown as { prisma_v3: PrismaClient }
const globalForPrisma = globalThis as unknown as { prisma_v4: PrismaClient }
export const prisma = globalForPrisma.prisma_v3 || makePrisma()
export const prisma = globalForPrisma.prisma_v4 || makePrisma()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v3 = prisma
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v4 = prisma
export default prisma

133
app/HomeClient.tsx Normal file
View File

@@ -0,0 +1,133 @@
"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';
import HomeSlider from '@/components/HomeSlider';
interface Props {
initialFeatured: Business[];
initialPosts: BlogPost[];
initialCategories: any[];
initialSlides: any[];
}
const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialSlides }: 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 [slides, setSlides] = useState<any[]>(initialSlides);
const [loading, setLoading] = useState(false);
const [loadingPosts, setLoadingPosts] = useState(false);
const handleSearch = (searchTerm: string, locationTerm: string) => {
const params = new URLSearchParams();
if (searchTerm) params.append('q', searchTerm);
if (locationTerm) params.append('location', locationTerm);
router.push(`/annuaire?${params.toString()}`);
};
return (
<div>
{/* Dynamic Hero Slider */}
<HomeSlider slides={slides} onSearch={handleSearch} />
{/* 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="/actualites" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir les actualités <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={`/actualites/${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;

42
app/actions/events.ts Normal file
View File

@@ -0,0 +1,42 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { ContentStatus } from "@prisma/client";
export async function createEvent(data: {
title: string;
description: string;
date: string;
location: string;
thumbnailUrl: string;
link?: string;
tags?: string[];
}) {
try {
const slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const event = await prisma.event.create({
data: {
title: data.title,
description: data.description,
date: new Date(data.date),
location: data.location,
thumbnailUrl: data.thumbnailUrl,
link: data.link || null,
tags: data.tags || [],
status: ContentStatus.PENDING, // Always pending for user submission
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
}
});
revalidatePath("/evenements");
return { success: true, event };
} catch (error) {
console.error("Failed to create event:", error);
return { success: false, error: "Erreur lors de la création de l'événement" };
}
}

40
app/actions/news.ts Normal file
View File

@@ -0,0 +1,40 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { ContentStatus } from "@prisma/client";
export async function createNews(data: {
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
tags?: string[];
}) {
try {
const slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const post = await prisma.blogPost.create({
data: {
title: data.title,
excerpt: data.excerpt,
content: data.content,
author: data.author,
imageUrl: data.imageUrl,
tags: data.tags || [],
status: ContentStatus.PENDING, // Always pending for user submission
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
}
});
revalidatePath("/blog");
return { success: true, post };
} catch (error) {
console.error("Failed to create news:", error);
return { success: false, error: "Erreur lors de la création de l'actualité" };
}
}

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

@@ -0,0 +1,236 @@
import React from 'react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, ArrowRight, User, Calendar, Share2 } from 'lucide-react';
import { sanitizeHTML } from '../../../lib/sanitize';
import { prisma } from '../../../lib/prisma';
import BlogShareButtons from '../../../components/BlogShareButtons';
import { Metadata } from 'next';
interface Props {
params: Promise<{ id: string }>;
}
// Style spécifique pour empêcher strictement la coupure des mots tout en justifiant le texte
const noBreakStyle = `
#blog-content,
#blog-content p,
#blog-content li,
#blog-content span,
#blog-content strong {
word-break: normal !important;
overflow-wrap: break-word !important;
hyphens: none !important;
line-break: normal !important;
text-align: justify !important;
text-justify: inter-word !important;
}
/* Sécurité supplémentaire pour les navigateurs mobiles */
.prose * {
word-break: normal !important;
hyphens: none !important;
}
`;
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await prisma.blogPost.findFirst({
where: {
OR: [{ id: id }, { slug: id }],
publishedAt: { lte: new Date() },
status: 'PUBLISHED'
}
});
if (!post) return { title: 'Article non trouvé' };
return {
title: post.metaTitle || post.title,
description: post.metaDescription || post.excerpt,
keywords: post.tags,
openGraph: {
title: post.metaTitle || post.title,
description: post.metaDescription || post.excerpt,
images: [post.imageUrl],
type: 'article',
}
};
}
export default async function BlogPostPage({ params }: Props) {
const { id } = await params;
const post = await prisma.blogPost.findFirst({
where: {
OR: [{ id: id }, { slug: id }],
publishedAt: { lte: new Date() },
status: 'PUBLISHED'
}
});
if (!post) {
notFound();
}
// Articles en rapport : par tags communs, sinon les plus récents
let relatedPosts = await prisma.blogPost.findMany({
where: {
status: 'PUBLISHED',
publishedAt: { lte: new Date() },
id: { not: post.id },
tags: { hasSome: post.tags }
},
take: 3,
orderBy: { date: 'desc' }
});
// Compléter avec les articles récents si nécessaire
if (relatedPosts.length < 2) {
const excludedIds = [post.id, ...relatedPosts.map(p => p.id)];
const additionalPosts = await prisma.blogPost.findMany({
where: {
status: 'PUBLISHED',
publishedAt: { lte: new Date() },
id: { notIn: excludedIds }
},
take: 3 - relatedPosts.length,
orderBy: { date: 'desc' }
});
relatedPosts = [...relatedPosts, ...additionalPosts];
}
return (
<article className="bg-gray-50 min-h-screen py-12 relative">
{/* Injection du style de force pour le texte */}
<style dangerouslySetInnerHTML={{ __html: noBreakStyle }} />
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 flex gap-4 lg:gap-8 justify-center relative">
{/* Conteneur principal de l'article */}
<div className="w-[calc(100%-3rem)] sm:w-full max-w-3xl relative">
{/* Navigation */}
<div className="mb-8">
<Link href="/actualites" className="inline-flex items-center text-gray-500 hover:text-brand-600 text-sm font-medium transition-colors">
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
</Link>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 md:p-10 border border-gray-100 relative z-10">
{/* Image d'en-tête */}
<div className="w-full h-64 md:h-80 relative mb-8 rounded-lg overflow-hidden">
<img
src={post.imageUrl}
alt={post.title}
className="w-full h-full object-cover"
/>
</div>
{/* Header de l'article */}
<header className="mb-8 border-b border-gray-100 pb-8">
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
<span className="flex items-center">
<User className="w-4 h-4 mr-1 text-brand-500" />
{post.author}
</span>
<span className="flex items-center">
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
{post.tags.length > 0 ? (
post.tags.map(tag => (
<span key={tag} className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
{tag}
</span>
))
) : (
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
Article
</span>
)}
</div>
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
{post.title}
</h1>
</header>
{/* Contenu de l'article */}
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
{/* Résumé / Lead */}
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
{post.excerpt}
</p>
{/* Corps du texte avec ID pour le contrôle CSS */}
<div
id="blog-content"
className="mt-6"
dangerouslySetInnerHTML={{ __html: sanitizeHTML(post.content) }}
/>
</div>
{/* Partage / Footer */}
<div className="mt-12 pt-8 border-t border-gray-100 flex flex-col sm:flex-row justify-between items-center gap-4">
<p className="text-gray-500 font-medium">Vous avez aimé cet article ?</p>
<Link
href="/actualites"
className="inline-flex items-center text-brand-600 hover:text-brand-700 font-semibold transition-colors"
>
Lire d'autres articles
<ArrowRight className="ml-2 w-4 h-4" />
</Link>
</div>
</div>
</div>
{/* Sidebar flottante verticale à droite (suit l'article et s'arrête à la fin) */}
<div className="w-10 shrink-0 relative">
<div className="sticky top-32 z-50">
<BlogShareButtons title={post.title} />
</div>
</div>
</div>
{/* Section Articles en rapport */}
{relatedPosts.length > 0 && (
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 mt-16 pt-16 border-t border-gray-200">
<h2 className="text-2xl md:text-3xl font-serif font-bold text-gray-900 mb-8">Articles en rapport</h2>
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{relatedPosts.map((rPost) => (
<Link
key={rPost.id}
href={`/actualites/${rPost.slug || rPost.id}`}
className="group bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100 hover:shadow-md transition-shadow"
>
<div className="aspect-video relative overflow-hidden">
<img
src={rPost.imageUrl}
alt={rPost.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-black/5 group-hover:bg-transparent transition-colors" />
</div>
<div className="p-5">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-600 bg-brand-50 px-2 py-0.5 rounded">
{rPost.tags?.[0] || 'Article'}
</span>
<span className="text-xs text-gray-400">
{new Date(rPost.date).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })}
</span>
</div>
<h3 className="text-lg font-bold text-gray-900 group-hover:text-brand-600 transition-colors line-clamp-2 leading-snug">
{rPost.title}
</h3>
<p className="mt-2 text-sm text-gray-500 line-clamp-2">
{rPost.excerpt}
</p>
</div>
</Link>
))}
</div>
</div>
)}
</article>
);
}

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) {
@@ -20,9 +26,9 @@ export default async function BlogPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<h1 className="text-3xl font-bold font-serif text-gray-900 sm:text-4xl">Le Blog de l'Entrepreneur</h1>
<h1 className="text-3xl font-bold font-serif text-gray-900 sm:text-4xl">Nos Actualités</h1>
<p className="mt-3 max-w-2xl mx-auto text-xl text-gray-500 sm:mt-4">
Conseils, actualités et success stories de l'écosystème africain.
Conseils, dossiers et news de l'écosystème africain.
</p>
</div>
@@ -34,7 +40,7 @@ export default async function BlogPage() {
) : (
<div className="grid gap-8 lg:grid-cols-3">
{posts.map(post => (
<Link key={post.id} href={`/blog/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
<Link key={post.id} href={`/actualites/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
<div className="flex-shrink-0 relative overflow-hidden h-48">
<img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} />
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>

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
}
}
// Build the conditions array
const conditions: any[] = [];
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;
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 = [
{ name: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
// Simplified tags check
{ tags: { has: q } }
]
conditions.push({
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ 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)

View File

@@ -0,0 +1,14 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET() {
try {
const plans = await prisma.oneShotPlan.findMany({
orderBy: { type: 'asc' }
});
return NextResponse.json(plans);
} catch (error) {
console.error("Failed to fetch one-shot plans:", error);
return NextResponse.json({ error: "Failed to fetch plans" }, { status: 500 });
}
}

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

@@ -1,128 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, User, Calendar, Share2 } from 'lucide-react';
import { prisma } from '../../../lib/prisma';
import { Metadata } from 'next';
interface Props {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await prisma.blogPost.findFirst({
where: {
OR: [
{ id: id },
{ slug: id }
]
}
});
if (!post) return { title: 'Article non trouvé' };
return {
title: post.metaTitle || post.title,
description: post.metaDescription || post.excerpt,
keywords: post.tags,
openGraph: {
title: post.metaTitle || post.title,
description: post.metaDescription || post.excerpt,
images: [post.imageUrl],
type: 'article',
}
};
}
export default async function BlogPostPage({ params }: Props) {
const { id } = await params;
const post = await prisma.blogPost.findFirst({
where: {
OR: [
{ id: id },
{ slug: id }
]
}
});
if (!post) {
notFound();
}
return (
<article className="bg-white min-h-screen">
{/* Hero Image */}
<div className="w-full h-64 md:h-96 relative">
<img
src={post.imageUrl}
alt={post.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
<div className="absolute bottom-0 left-0 w-full p-4 sm:p-8 max-w-4xl mx-auto">
<Link href="/blog" className="inline-flex items-center text-white/80 hover:text-white mb-4 text-sm font-medium transition-colors">
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
</Link>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12 -mt-20 relative z-10">
<div className="bg-white rounded-xl shadow-xl p-6 md:p-10 border border-gray-100">
{/* Header */}
<header className="mb-8 border-b border-gray-100 pb-8">
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
<span className="flex items-center">
<User className="w-4 h-4 mr-1 text-brand-500" />
{post.author}
</span>
<span className="flex items-center">
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
{post.tags.length > 0 ? (
post.tags.map(tag => (
<span key={tag} className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
{tag}
</span>
))
) : (
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
Article
</span>
)}
</div>
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
{post.title}
</h1>
</header>
{/* Content */}
<div className="prose prose-lg prose-orange max-w-none text-gray-600 break-words overflow-hidden">
<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 }}
/>
</div>
{/* Footer / Share */}
<div className="mt-12 pt-8 border-t border-gray-100 flex justify-between items-center">
<p className="text-sm text-gray-500 font-medium">Vous avez aimé cet article ?</p>
<div className="flex space-x-2">
<button className="p-2 rounded-full bg-gray-100 text-gray-600 hover:bg-brand-100 hover:text-brand-600 transition-colors">
<Share2 className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
</article>
);
}

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,8 @@ import DashboardProfileClient from '../../components/dashboard/DashboardProfileC
import DashboardMessages from '../../components/dashboard/DashboardMessages';
import DashboardOffers from '../../components/dashboard/DashboardOffers';
import PricingSection from '../../components/PricingSection';
import OneShotPricingSection from '../../components/OneShotPricingSection';
import DashboardFavorites from '../../components/dashboard/DashboardFavorites';
import { useUser } from '../../components/UserProvider';
const DashboardContent = () => {
@@ -21,7 +23,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 +35,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 +222,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>
@@ -236,7 +243,7 @@ const DashboardContent = () => {
</button>
<button onClick={() => setCurrentView('subscription')} className={`${currentView === 'subscription' ? '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`}>
<CreditCard className={`${currentView === 'subscription' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Abonnement
Abonnement & Services
</button>
</>
) : (
@@ -267,15 +274,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">
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
Voir ma fiche
</a>
{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
</Link>
)}
</div>
</header>
@@ -302,6 +312,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' && (
@@ -316,6 +327,7 @@ const DashboardContent = () => {
</span>
</div>
<PricingSection />
<OneShotPricingSection />
</div>
)}
</div>

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;
@@ -23,4 +23,38 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: var(--font-sans);
text-align: left;
}
/* FORCE ABSOLUE SUR LE CONTENU DU BLOG
L'utilisation de l'ID #blog-content garantit que ces règles
écrasent les styles par défaut du plugin typography.
*/
#blog-content,
#blog-content *,
.prose p,
.prose li {
word-break: keep-all !important;
/* Interdit de couper les mots */
overflow-wrap: break-word !important;
/* Autorise le retour à la ligne si le mot est trop long */
hyphens: none !important;
/* Supprime les tirets de césure */
line-break: normal !important;
/* Empêche les cassures de caractères bizarres */
}
/* Nettoyage des titres */
h1,
h2,
h3,
h4 {
word-break: normal !important;
overflow-wrap: break-word !important;
hyphens: none !important;
}
.prose p {
margin-bottom: 1.25em;
text-align: left;
}

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"
@@ -93,6 +186,7 @@ const LoginPage = () => {
</button>
</div>
</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>
);
}

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