Compare commits

..

39 Commits

Author SHA1 Message Date
af6ec84cd0 maj metric article 2026-06-21 17:24:31 +02:00
ecfe7fb433 maj api + correction bug 2026-06-08 21:23:39 +02:00
0054f731c7 feat: add JSON import functionality for content creation in the admin dashboard 2026-05-17 13:23:11 +02:00
8f0dc7f38c add DashboardMessages component for real-time chat functionality 2026-05-14 10:09:23 +02:00
ddb37e057d gestion des messages page 404 2026-05-13 21:19:15 +02:00
eb43d65280 correction responsive 2026-05-12 20:32:00 +02:00
bd6380b7aa correction responsive mobile 2026-05-12 20:18:43 +02:00
streap2
b81216210a feat: add home featured toggle, improve account deletion UI, and update business schema and database configuration. 2026-05-12 07:17:04 +02:00
01cf4dd5a1 gestion supp user admin 2026-05-11 22:55:30 +02:00
2cab2ddd1d admin build v3 2026-05-11 17:57:54 +02:00
cfc5f1ac02 correction V2 prisma 2026-05-11 17:45:05 +02:00
139d6991f5 correction npm run build admin 2026-05-11 13:47:27 +02:00
1df2503f0b correction du deploiement pour le front 2026-05-11 09:18:39 +02:00
ea9eaa4b8a monorepo admin supp prisma 2026-05-11 08:37:17 +02:00
3c71e483ac npmrc admin 2026-05-10 22:26:04 +02:00
1b47498f6e feat: add CountryHistogram and AnalyticsMap components and update build script to use legacy-peer-deps 2026-05-10 22:01:03 +02:00
197594f84f feat: implement event submission flow with image upload and modal interface 2026-05-10 21:47:28 +02:00
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
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
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
75ae9c5f3d pré supp dockerfile 2026-05-04 20:49:21 +02:00
113 changed files with 11634 additions and 9590 deletions

2
.env
View File

@@ -5,4 +5,4 @@ DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?sch
# Exemple avec Supabase ou Neon (si base distante) # 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 # Environnement : "development" = accents rouges, "production" = accents normaux
NEXT_PUBLIC_APP_ENV=development NEXT_PUBLIC_APP_ENV=development

1
admin/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -1,12 +1,24 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import path from "path"; import path from "path";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */
output: 'standalone', output: 'standalone',
turbopack: { allowedDevOrigins: ['10.0.2.2'],
root: path.join(__dirname, ".."),
// 1. On ignore les erreurs de type et ESLint pour que le build
// de production ne plante pas à cause des imports Prisma
typescript: {
ignoreBuildErrors: true,
},
// 3. (Optionnel) Pour faciliter le chargement des images si tu en as
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
}, },
}; };
export default nextConfig; export default nextConfig;

7337
admin/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,33 +4,36 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "prisma generate && next build", "build": "prisma generate --schema=../prisma/schema.prisma && next build",
"start": "next start -p 3000", "start": "next start -p 3000",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "6.19.3",
"@prisma/client": "6.19.3",
"@prisma/config": "6.19.3",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"afrohub": "file:..", "afrohub": "file:..",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"d3-scale": "^4.0.2",
"dotenv": "^17.4.1", "dotenv": "^17.4.1",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "16.2.3", "next": "16.2.3",
"pg": "^8.20.0", "pg": "^8.20.0",
"prisma": "6.19.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-quill-new": "^3.8.3" "react-quill-new": "^3.8.3",
"react-simple-maps": "^3.0.0",
"recharts": "^3.8.1",
"topojson-client": "^3.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/d3-scale": "^4.0.9",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/react-simple-maps": "^3.0.6",
"@types/topojson-client": "^3.1.5",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.3", "eslint-config-next": "16.2.3",
"tailwindcss": "^4", "tailwindcss": "^4",

View File

@@ -1,459 +0,0 @@
-- 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

@@ -1,8 +0,0 @@
-- 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

@@ -1,11 +0,0 @@
-- 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

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

View File

@@ -1,379 +0,0 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl", "linux-musl-openssl-3.0.x"]
}
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?
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[]
sentMessages Message[]
reports MessageReport[]
ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
favorites Favorite[]
}
model Business {
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?
metaTitle String?
metaDescription String?
plan Plan @default(STARTER)
city String?
countryId String?
coverUrl String?
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
suggestedCategory String?
categoryId String?
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
country Country? @relation(fields: [countryId], references: [id])
owner User @relation(fields: [ownerId], references: [id])
comments Comment[]
conversations Conversation[]
offers Offer[]
ratings Rating[]
favorites Favorite[]
}
model BusinessCategory {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
businesses Business[]
}
model Country {
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
priceEUR String
description String
features String[]
offerLimit Int @default(1)
recommended Boolean @default(false)
color String @default("gray")
updatedAt DateTime @updatedAt
yearlyPriceEUR String?
yearlyPriceXOF String?
}
model Offer {
id String @id @default(uuid())
title String
type OfferType
price Float
currency String @default("XOF")
description String?
imageUrl String
active Boolean @default(true)
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
}
model BlogPost {
id String @id @default(uuid())
title String
excerpt String
content String
author String
date DateTime @default(now())
imageUrl String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metaDescription String?
metaTitle String?
slug String? @unique
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
}
model Interview {
id String @id @default(uuid())
title String
guestName String
companyName String
role String
type InterviewType
thumbnailUrl String
videoUrl String?
content String?
excerpt String
date DateTime @default(now())
duration 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 {
id String @id @default(uuid())
content String
authorId String
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
}
model AnalyticsEvent {
id String @id @default(uuid())
type String
path String
label String?
value Float?
metadata Json?
createdAt DateTime @default(now())
browser String?
city String?
country String?
device String?
ip String?
os String?
referrer String?
userId String?
}
model Rating {
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)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, businessId])
}
model Conversation {
id String @id @default(uuid())
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
participants ConversationParticipant[]
messages Message[]
}
model ConversationParticipant {
id String @id @default(uuid())
userId String
conversationId String
isArchived Boolean @default(false)
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, conversationId])
}
model Message {
id String @id @default(uuid())
content String
senderId String
conversationId String
createdAt DateTime @default(now())
read Boolean @default(false)
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
reports MessageReport[]
}
model MessageReport {
id String @id @default(uuid())
reason String?
messageId String
reporterId String
status ReportStatus @default(PENDING)
createdAt DateTime @default(now())
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
}
model SiteSetting {
id String @id @default("singleton")
siteName String @default("Afrohub")
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
contactEmail String @default("support@afrohub.com")
contactPhone String? @default("+225 00 00 00 00 00")
address String? @default("Abidjan, Côte d'Ivoire")
facebookUrl String?
twitterUrl String?
instagramUrl String?
linkedinUrl String?
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
updatedAt DateTime @updatedAt
homeBlogCount Int @default(3)
homeBlogShow Boolean @default(true)
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
homeBlogTitle String @default("Derniers Articles")
homeBlogCategories String[] @default([])
homeBlogIds String[] @default([])
homeBlogSelection String @default("latest")
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
resetPasswordSubject String @default("Réinitialisation de votre mot de passe - Afrohub")
resetPasswordTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{resetUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">&copy; 2025 {siteName}. Tous droits réservés.</p></div>")
verifyEmailSubject String @default("Vérification de votre compte - Afrohub")
verifyEmailTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{verifyUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Vérifier mon compte</a></div><p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">&copy; 2025 {siteName}. Tous droits réservés.</p></div>")
}
model LegalDocument {
id String @id @default(uuid())
type String @unique
title String
content String
updatedAt DateTime @updatedAt
}
model Event {
id String @id @default(uuid())
title String
description String
date DateTime
location String
thumbnailUrl String
link String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metaDescription String?
metaTitle String?
slug String? @unique
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
}
model Favorite {
id String @id @default(uuid())
userId String
businessId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
@@unique([userId, businessId])
}
enum ContentStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
enum ReportStatus {
PENDING
RESOLVED
DISMISSED
}
enum UserRole {
VISITOR
ENTREPRENEUR
ADMIN
}
enum Plan {
STARTER
BOOSTER
EMPIRE
}
enum OfferType {
PRODUCT
SERVICE
}
enum InterviewType {
VIDEO
ARTICLE
}

View File

@@ -17,6 +17,9 @@ export async function createBlogPost(data: {
metaDescription?: string; metaDescription?: string;
publishedAt?: Date; publishedAt?: Date;
status?: ContentStatus; status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
sources?: any;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);
@@ -30,7 +33,8 @@ export async function createBlogPost(data: {
status: data.status || ContentStatus.PUBLISHED, status: data.status || ContentStatus.PUBLISHED,
}, },
}); });
revalidatePath("/blog"); revalidatePath("/actualites");
revalidatePath("/");
return { success: true, id: post.id }; return { success: true, id: post.id };
} catch (error) { } catch (error) {
console.error("Failed to create blog post:", error); console.error("Failed to create blog post:", error);
@@ -50,6 +54,9 @@ export async function updateBlogPost(id: string, data: {
metaDescription?: string; metaDescription?: string;
publishedAt?: Date; publishedAt?: Date;
status?: ContentStatus; status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
sources?: any;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);
@@ -62,7 +69,8 @@ export async function updateBlogPost(id: string, data: {
tags: data.tags || [], tags: data.tags || [],
}, },
}); });
revalidatePath("/blog"); revalidatePath("/actualites");
revalidatePath("/");
return { success: true, id: post.id }; return { success: true, id: post.id };
} catch (error) { } catch (error) {
console.error("Failed to update blog post:", error); console.error("Failed to update blog post:", error);
@@ -75,7 +83,8 @@ export async function deleteBlogPost(id: string) {
await prisma.blogPost.delete({ await prisma.blogPost.delete({
where: { id }, where: { id },
}); });
revalidatePath("/blog"); revalidatePath("/actualites");
revalidatePath("/");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Failed to delete blog post:", 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

@@ -24,21 +24,24 @@ export async function setFeaturedBusiness(id: string, data: {
keyMetric: string; keyMetric: string;
}) { }) {
try { try {
// 1. Reset all others (we only want one entrepreneur of the month) // 1. Réinitialiser les autres pour n'avoir qu'une seule Tête d'affiche Afroshine active
await prisma.business.updateMany({ await prisma.business.updateMany({
where: { isFeatured: true }, where: { isFeatured: true },
data: { isFeatured: false }, data: { isFeatured: false },
}); });
// 2. Set the new one // 2. Mettre à jour l'entreprise choisie
await prisma.business.update({ await prisma.business.update({
where: { id }, where: { id },
data: { data: {
isFeatured: true, isFeatured: true,
...data, founderName: data.founderName || null,
founderImageUrl: data.founderImageUrl || null,
keyMetric: data.keyMetric || null,
}, },
}); });
revalidatePath("/users");
revalidatePath("/entrepreneurs"); revalidatePath("/entrepreneurs");
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true };
@@ -48,12 +51,28 @@ export async function setFeaturedBusiness(id: string, data: {
} }
} }
export async function toggleHomeFeatured(id: string, currentStatus: boolean) {
try {
await prisma.business.update({
where: { id },
data: { isHomeFeatured: !currentStatus },
});
revalidatePath("/users");
revalidatePath("/");
return { success: true };
} catch (error) {
console.error("Failed to toggle home featured:", error);
return { success: false, error: "Erreur lors de la mise à jour de la mise à la une" };
}
}
export async function removeFeaturedBusiness(id: string) { export async function removeFeaturedBusiness(id: string) {
try { try {
await prisma.business.update({ await prisma.business.update({
where: { id }, where: { id },
data: { isFeatured: false }, data: { isFeatured: false },
}); });
revalidatePath("/users");
revalidatePath("/entrepreneurs"); revalidatePath("/entrepreneurs");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@@ -18,6 +18,8 @@ export async function createEvent(data: {
metaDescription?: string; metaDescription?: string;
publishedAt?: Date; publishedAt?: Date;
status?: ContentStatus; status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);
@@ -53,6 +55,8 @@ export async function updateEvent(id: string, data: {
metaDescription?: string; metaDescription?: string;
publishedAt?: Date; publishedAt?: Date;
status?: ContentStatus; status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); 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

@@ -29,6 +29,7 @@ export async function getSiteSettings() {
homeBlogIds: [], homeBlogIds: [],
homeBlogCategories: [], homeBlogCategories: [],
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"], homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"],
notFoundQuotes: ["L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."],
resetPasswordSubject: "Réinitialisation de votre mot de passe - Afrohub", 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;"> 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> <h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
@@ -83,3 +84,7 @@ export async function updateSiteSettings(data: any) {
return { success: false, error: "Erreur lors de la mise à jour des paramètres" }; return { success: false, error: "Erreur lors de la mise à jour des paramètres" };
} }
} }
export async function getExternalApiKey() {
return process.env.EXTERNAL_API_KEY || "ezfzeFZEfztZEgZEFzETGZEGZERZERF";
}

View File

@@ -0,0 +1,118 @@
"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,
coverPosition: data.coverPosition || "50% 50%",
coverZoom: data.coverZoom || 1,
}
});
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,
coverPosition: data.coverPosition || "50% 50%",
coverZoom: data.coverZoom || 1,
}
});
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

@@ -14,7 +14,9 @@ export async function uploadImage(formData: FormData) {
const buffer = Buffer.from(bytes); const buffer = Buffer.from(bytes);
// Create a unique filename // Create a unique filename
const fileExtension = file.name.split('.').pop(); let fileExtension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
if (fileExtension === 'jpeg') fileExtension = 'jpg';
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`; const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
// Path for the ROOT public/uploads (where the main site will look) // Path for the ROOT public/uploads (where the main site will look)

View File

@@ -41,3 +41,95 @@ export async function verifyUserEmail(userId: string) {
return { success: false, error: "Erreur lors de la vérification de l'email" }; return { success: false, error: "Erreur lors de la vérification de l'email" };
} }
} }
export async function scheduleUserDeletion(userId: string, reason: string, delayDays: number) {
try {
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user) return { success: false, error: "Utilisateur non trouvé" };
const settings = await prisma.siteSetting.findUnique({
where: { id: "singleton" }
});
const deletionDate = new Date();
deletionDate.setDate(deletionDate.getDate() + delayDays);
if (delayDays === 0) {
// Immediate deletion
await prisma.user.delete({
where: { id: userId }
});
return { success: true, message: "Supprimé immédiatement" };
}
await prisma.user.update({
where: { id: userId },
data: {
deletedAt: new Date(),
deletionScheduledAt: deletionDate,
deletionReason: reason
}
});
// Send notification email
if (settings?.deleteAccountTemplate) {
const { sendEmail } = await import("@/lib/mail");
const html = settings.deleteAccountTemplate
.replace(/{name}/g, user.name)
.replace(/{siteName}/g, settings.siteName)
.replace(/{deletionDate}/g, delayDays === 0 ? "immédiatement" : deletionDate.toLocaleDateString("fr-FR"))
.replace(/{reason}/g, reason)
.replace(/{reactivateUrl}/g, `${process.env.NEXT_PUBLIC_APP_URL}/auth/reactivate?id=${user.id}`);
await sendEmail({
to: user.email,
subject: settings.deleteAccountSubject,
html
});
}
return { success: true };
} catch (error) {
console.error("Failed to schedule user deletion:", error);
return { success: false, error: "Erreur lors de la programmation de la suppression" };
}
}
export async function reactivateUser(userId: string, reason: string) {
try {
const user = await prisma.user.update({
where: { id: userId },
data: {
deletedAt: null,
deletionScheduledAt: null,
reactivationReason: reason
}
});
const settings = await prisma.siteSetting.findUnique({
where: { id: "singleton" }
});
// Send confirmation email
if (settings?.reactivateAccountTemplate) {
const { sendEmail } = await import("@/lib/mail");
const html = settings.reactivateAccountTemplate
.replace(/{name}/g, user.name)
.replace(/{siteName}/g, settings.siteName)
.replace(/{reason}/g, reason);
await sendEmail({
to: user.email,
subject: settings.reactivateAccountSubject,
html
});
}
return { success: true };
} catch (error) {
console.error("Failed to reactivate user:", error);
return { success: false, error: "Erreur lors de la réactivation de l'utilisateur" };
}
}

View File

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

View File

@@ -0,0 +1,13 @@
import ImportJsonForm from '@/components/ImportJsonForm';
export const metadata = {
title: 'Import JSON - CMS Afrohub',
};
export default function ImportJsonPage() {
return (
<div className="py-8">
<ImportJsonForm />
</div>
);
}

View File

@@ -0,0 +1,7 @@
import ActualitesForm from "@/components/ActualitesForm";
export const dynamic = 'force-dynamic';
export default function NewActualitesPage() {
return <ActualitesForm />;
}

View File

@@ -1,9 +1,11 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import Link from 'next/link'; import Link from 'next/link';
import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin } from 'lucide-react'; import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin, Clock, ShieldCheck, ShieldAlert } from 'lucide-react';
import DeleteBlogButton from '@/components/DeleteBlogButton'; import DeleteActualitesButton from '@/components/DeleteActualitesButton';
import DeleteInterviewButton from '@/components/DeleteInterviewButton'; import DeleteInterviewButton from '@/components/DeleteInterviewButton';
import DeleteEventButton from '@/components/DeleteEventButton'; import DeleteEventButton from '@/components/DeleteEventButton';
import ApproveContentButton from '@/components/ApproveContentButton';
import { FileJson } from 'lucide-react';
async function getData() { async function getData() {
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } }); const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
@@ -14,25 +16,30 @@ async function getData() {
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { 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 '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 'PUBLISHED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">PUBLIÉ</span>;
case 'ARCHIVED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20">ARCHIVÉ</span>; 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; default: return null;
} }
} }
export default async function BlogCMSPage() { export default async function ActualitesCMSPage() {
const { posts, interviews, events } = await getData(); 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
<div className="flex justify-between items-end mb-8"> <div className="flex justify-between items-end mb-8">
<div> <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> <p className="text-slate-400">Gérez le contenu éditorial et événementiel de la plateforme.</p>
</div> </div>
<div className="flex flex-wrap gap-4"> <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" /> <Plus className="w-4 h-4" />
Nouvel Article Nouvel Article
</Link> </Link>
@@ -44,15 +51,81 @@ export default async function BlogCMSPage() {
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Nouvel Événement Nouvel Événement
</Link> </Link>
</div> </div>
</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"> <div className="grid grid-cols-1 gap-8">
{/* Blog Posts Section */} {/* Blog Posts Section */}
<div className="card"> <div className="card">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<BookOpen className="text-indigo-400 w-5 h-5" /> <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>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="data-table w-full"> <table className="data-table w-full">
@@ -77,10 +150,10 @@ export default async function BlogCMSPage() {
<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-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
<td className="py-4 px-4 text-right"> <td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2"> <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" /> <Edit className="w-5 h-5" />
</Link> </Link>
<DeleteBlogButton id={post.id} /> <DeleteActualitesButton id={post.id} />
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
/**
* Route pour le nettoyage automatique des comptes
* À appeler via une tâche cron (ex: chaque nuit à minuit)
* GET /api/cron/cleanup-users?key=VOTRE_CLE_SECRET
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const key = searchParams.get("key");
if (key !== process.env.CRON_SECRET) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
const now = new Date();
// 1. Trouver les utilisateurs dont le délai est passé
const usersToDelete = await prisma.user.findMany({
where: {
deletionScheduledAt: {
lte: now
}
}
});
if (usersToDelete.length === 0) {
return NextResponse.json({ message: "Aucun compte à supprimer" });
}
// 2. Suppression définitive
// Prisma supprimera les données liées si onDelete: Cascade est configuré dans le schéma
const deleteResult = await prisma.user.deleteMany({
where: {
id: {
in: usersToDelete.map(u => u.id)
}
}
});
return NextResponse.json({
message: `${deleteResult.count} compte(s) supprimé(s) définitivement`,
deletedIds: usersToDelete.map(u => u.id)
});
} catch (error) {
console.error("Cron Cleanup Error:", error);
return NextResponse.json({ error: "Erreur lors du nettoyage" }, { status: 500 });
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { generateSlug } from '@/lib/utils'
import { revalidatePath } from 'next/cache'
export async function POST(request: NextRequest) {
try {
// Simple API Key authentication
const apiKey = request.headers.get('x-api-key')
const expectedApiKey = process.env.EXTERNAL_API_KEY
if (!expectedApiKey) {
console.error('EXTERNAL_API_KEY is not defined in environment variables.')
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 })
}
if (apiKey !== expectedApiKey) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Validation
const { title, excerpt, content, author, imageUrl, type, tags, status, publishedAt } = body
if (!title || !excerpt || !content || !author || !imageUrl) {
return NextResponse.json({
error: 'Missing required fields: title, excerpt, content, author, imageUrl'
}, { status: 400 })
}
const rawSlug = generateSlug(title)
const slug = `${rawSlug}-${Math.random().toString(36).substring(2, 7)}`
// Si un "type" est fourni, on l'ajoute automatiquement aux tags pour le catégoriser
const finalTags = tags || []
if (type && typeof type === 'string' && !finalTags.includes(type)) {
finalTags.push(type)
}
const post = await prisma.blogPost.create({
data: {
title,
excerpt,
content,
author,
imageUrl,
slug,
tags: finalTags,
status: status || 'PUBLISHED',
publishedAt: publishedAt ? new Date(publishedAt) : new Date(Date.now() - 60000), // -1 min to ensure visibility
date: new Date()
}
})
revalidatePath('/actualites')
revalidatePath('/')
return NextResponse.json({ success: true, post }, { status: 201 })
} catch (error) {
console.error('POST /api/external/articles error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { generateSlug } from '@/lib/utils'
import { revalidatePath } from 'next/cache'
export async function POST(request: NextRequest) {
try {
// Simple API Key authentication
const apiKey = request.headers.get('x-api-key')
const expectedApiKey = process.env.EXTERNAL_API_KEY
if (!expectedApiKey) {
console.error('EXTERNAL_API_KEY is not defined in environment variables.')
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 })
}
if (apiKey !== expectedApiKey) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Validation
const { title, description, date, location, thumbnailUrl, link, tags, status, publishedAt } = body
if (!title || !description || !date || !location || !thumbnailUrl) {
return NextResponse.json({
error: 'Missing required fields: title, description, date, location, thumbnailUrl'
}, { status: 400 })
}
const rawSlug = generateSlug(title)
const slug = `${rawSlug}-${Math.random().toString(36).substring(2, 7)}`
const event = await prisma.event.create({
data: {
title,
description,
date: new Date(date),
location,
thumbnailUrl,
link,
slug,
tags: tags || [],
status: status || 'PUBLISHED',
publishedAt: publishedAt ? new Date(publishedAt) : new Date()
}
})
revalidatePath('/evenements')
revalidatePath('/')
return NextResponse.json({ success: true, event }, { status: 201 })
} catch (error) {
console.error('POST /api/external/events error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

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

View File

@@ -6,19 +6,48 @@ import {
Store, Store,
Clock, Clock,
MessageCircle, MessageCircle,
Eye Eye,
CheckCircle2,
UserPlus
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link';
interface Stats { interface DashboardData {
usersCount: number; stats: {
businessCount: number; usersCount: number;
pendingCount: number; businessCount: number;
commentsCount: number; pendingCount: number;
totalViews: number; commentsCount: number;
uniqueVisitors: number; totalViews: number;
uniqueVisitors: number;
};
latestBusinesses: Array<{
id: string;
name: string;
category: string;
logoUrl: string;
location: string;
createdAt: Date;
verified: boolean;
plan: string;
owner?: {
name: string;
email: string;
};
}>;
activities: Array<{
id: string;
type: 'comment' | 'user';
title: string;
subtitle: string;
timestamp: Date;
avatar?: string | null;
}>;
} }
export default function DashboardClient({ stats }: { stats: Stats }) { export default function DashboardClient({ initialData }: { initialData: DashboardData }) {
const { stats, latestBusinesses, activities } = initialData;
const statCards = [ const statCards = [
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' }, { label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' }, { label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
@@ -50,16 +79,86 @@ export default function DashboardClient({ stats }: { stats: Stats }) {
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="card"> {/* Derniers entrepreneurs */}
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2> <div className="card flex flex-col">
<div className="space-y-4"> <div className="flex justify-between items-center mb-6">
<p className="text-slate-500 italic">Bientôt disponible...</p> <h2 className="text-xl font-bold text-white">Derniers entrepreneurs</h2>
<Link href="/users" className="text-xs text-indigo-400 hover:underline font-semibold">
Voir tout
</Link>
</div>
<div className="space-y-4 flex-1">
{latestBusinesses.length === 0 ? (
<p className="text-slate-500 italic py-8 text-center">Aucune entreprise récente.</p>
) : (
latestBusinesses.map((b) => (
<div key={b.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-800/40 hover:bg-slate-800/80 transition-colors border border-slate-800">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-slate-800 overflow-hidden flex items-center justify-center border border-slate-700 flex-shrink-0">
{b.logoUrl ? (
<img src={b.logoUrl} alt={b.name} className="w-full h-full object-cover" />
) : (
<Store className="w-4 h-4 text-slate-500" />
)}
</div>
<div className="min-w-0">
<div className="font-semibold text-white text-sm truncate">{b.name}</div>
<div className="text-xs text-slate-400 truncate">{b.category} {b.location}</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase ${
b.plan === 'EMPIRE' ? 'bg-purple-500/10 text-purple-400 border border-purple-500/20' :
b.plan === 'BOOSTER' ? 'bg-amber-500/10 text-amber-400 border border-amber-500/20' :
'bg-slate-500/10 text-slate-400'
}`}>
{b.plan}
</span>
{b.verified ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500" title="Vérifié" />
) : (
<Clock className="w-4 h-4 text-amber-500" title="En attente" />
)}
</div>
</div>
))
)}
</div> </div>
</div> </div>
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2> {/* Activité récente */}
<div className="space-y-4"> <div className="card flex flex-col">
<p className="text-slate-500 italic">Bientôt disponible...</p> <h2 className="text-xl font-bold text-white mb-6">Activité récente</h2>
<div className="space-y-4 flex-1">
{activities.length === 0 ? (
<p className="text-slate-500 italic py-8 text-center">Aucune activité enregistrée.</p>
) : (
activities.map((act) => (
<div key={act.id} className="flex items-start gap-3.5 p-3 rounded-xl bg-slate-800/20 border border-slate-800/50">
<div className="mt-0.5 w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center border border-slate-700 flex-shrink-0 overflow-hidden">
{act.avatar ? (
<img src={act.avatar} alt="" className="w-full h-full object-cover" />
) : act.type === 'comment' ? (
<MessageCircle className="w-3.5 h-3.5 text-purple-400" />
) : (
<UserPlus className="w-3.5 h-3.5 text-blue-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-slate-200 truncate">{act.title}</p>
<span className="text-[10px] text-slate-500 flex-shrink-0">
{new Date(act.timestamp).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5 line-clamp-2">{act.subtitle}</p>
</div>
</div>
))
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import DashboardClient from './DashboardClient'; import DashboardClient from './DashboardClient';
async function getStats() { async function getDashboardData() {
const usersCount = await prisma.user.count(); const usersCount = await prisma.user.count();
const businessCount = await prisma.business.count(); const businessCount = await prisma.business.count();
const pendingCount = await prisma.business.count({ where: { verified: false } }); const pendingCount = await prisma.business.count({ where: { verified: false } });
@@ -21,10 +21,81 @@ async function getStats() {
}); });
const uniqueVisitors = uniqueVisitorsRes.length; const uniqueVisitors = uniqueVisitorsRes.length;
return { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors }; // Derniers entrepreneurs
const latestBusinesses = await prisma.business.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
category: true,
logoUrl: true,
location: true,
createdAt: true,
verified: true,
plan: true,
owner: {
select: {
name: true,
email: true,
}
}
}
});
// Activité récente (combiner commentaires et nouveaux utilisateurs)
const recentComments = await prisma.comment.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
content: true,
createdAt: true,
author: { select: { name: true, avatar: true } },
business: { select: { name: true } }
}
});
const recentUsers = await prisma.user.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
role: true,
avatar: true,
createdAt: true,
}
});
// Mapper et trier
const activities = [
...recentComments.map(c => ({
id: `comment-${c.id}`,
type: 'comment' as const,
title: `${c.author?.name || 'Un utilisateur'} a commenté sur ${c.business?.name || 'une entreprise'}`,
subtitle: c.content.length > 60 ? c.content.substring(0, 60) + '...' : c.content,
timestamp: c.createdAt,
avatar: c.author?.avatar
})),
...recentUsers.map(u => ({
id: `user-${u.id}`,
type: 'user' as const,
title: `Nouvel utilisateur inscrit`,
subtitle: `${u.name} a rejoint la plateforme en tant que ${u.role === 'ENTREPRENEUR' ? 'Entrepreneur' : 'Visiteur'}`,
timestamp: u.createdAt,
avatar: u.avatar
}))
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()).slice(0, 6);
return {
stats: { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors },
latestBusinesses,
activities
};
} }
export default async function DashboardPage() { export default async function DashboardPage() {
const stats = await getStats(); const data = await getDashboardData();
return <DashboardClient stats={stats} />; return <DashboardClient initialData={data} />;
} }

View File

@@ -17,6 +17,7 @@ body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow-x: hidden;
} }
/* Scrollbar */ /* Scrollbar */
@@ -42,12 +43,16 @@ body {
position: fixed; position: fixed;
left: 0; left: 0;
top: 0; top: 0;
z-index: 50;
} }
.admin-content { .admin-content {
margin-left: 260px; margin-left: 260px;
padding: 2rem; padding: 2rem;
min-height: 100vh; min-height: 100vh;
width: calc(100% - 260px);
max-width: calc(100vw - 260px);
box-sizing: border-box;
} }
.nav-link { .nav-link {
@@ -299,3 +304,23 @@ body {
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before { .quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
background-color: var(--primary); background-color: var(--primary);
} }
/* Styles pour les tableaux dans l'éditeur */
.quill-dark-wrapper .ql-editor table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
border: 1px solid var(--border);
}
.quill-dark-wrapper .ql-editor th,
.quill-dark-wrapper .ql-editor td {
border: 1px solid var(--border);
padding: 0.5rem;
text-align: left;
}
.quill-dark-wrapper .ql-editor th {
background-color: var(--secondary);
font-weight: 600;
}

View File

@@ -20,10 +20,10 @@ export default function RootLayout({
return ( return (
<html lang="fr"> <html lang="fr">
<body className={inter.className}> <body className={inter.className}>
<Toaster position="bottom-right" />
<div className="flex min-h-screen"> <div className="flex min-h-screen">
<Sidebar /> <Sidebar />
<main className="admin-content flex-1"> <main className="admin-content flex-1 min-w-0">
<Toaster position="top-right" />
{children} {children}
</main> </main>
</div> </div>

View File

@@ -13,8 +13,12 @@ import {
Search, Search,
ArrowUp, ArrowUp,
ArrowDown, ArrowDown,
Calendar Calendar,
Map as MapIcon,
BookOpen
} from 'lucide-react'; } from 'lucide-react';
import AnalyticsMap from '@/components/AnalyticsMap';
import CountryHistogram from '@/components/CountryHistogram';
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) { function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
if (value === null) return null; if (value === null) return null;
@@ -76,12 +80,28 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
const topCountries = await prisma.analyticsEvent.groupBy({ const topCountries = await prisma.analyticsEvent.groupBy({
by: ['country'], by: ['country'],
where: currentWhere, where: { type: 'PAGE_VIEW', ...currentWhere },
_count: { id: true }, _count: { id: true },
orderBy: { _count: { id: 'desc' } }, orderBy: { _count: { id: 'desc' } },
take: 10 take: 50 // Increased to get more map data
}); });
// Unique visitors per country
const uniqueVisitorsPerCountryRaw = await prisma.analyticsEvent.groupBy({
by: ['country', 'ip'],
where: { type: 'PAGE_VIEW', ...currentWhere }
});
const uniqueVisitorsPerCountryMap: Record<string, number> = {};
uniqueVisitorsPerCountryRaw.forEach(item => {
const country = item.country || 'Unknown';
uniqueVisitorsPerCountryMap[country] = (uniqueVisitorsPerCountryMap[country] || 0) + 1;
});
const uniqueVisitorsPerCountry = Object.entries(uniqueVisitorsPerCountryMap)
.map(([country, count]) => ({ country, count }))
.sort((a, b) => b.count - a.count);
const devices = await prisma.analyticsEvent.groupBy({ const devices = await prisma.analyticsEvent.groupBy({
by: ['device'], by: ['device'],
where: currentWhere, where: currentWhere,
@@ -104,10 +124,63 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
}); });
const uniqueVisitors = uniqueVisitorsRes.length; const uniqueVisitors = uniqueVisitorsRes.length;
// Article page views (impressions)
const pageViewsByPath = await prisma.analyticsEvent.groupBy({
by: ['path'],
where: {
type: 'PAGE_VIEW',
path: { startsWith: '/actualites/' },
...currentWhere
},
_count: { id: true }
});
// Article dwell times (reading times)
const dwellTimeByPath = await prisma.analyticsEvent.groupBy({
by: ['path'],
where: {
type: 'DWELL_TIME',
path: { startsWith: '/actualites/' },
...currentWhere
},
_avg: { value: true },
_count: { id: true }
});
// Article unique visitors grouping
const uniqueVisitorsRaw = await prisma.analyticsEvent.groupBy({
by: ['path', 'ip'],
where: {
type: 'PAGE_VIEW',
path: { startsWith: '/actualites/' },
...currentWhere
}
});
// Load blog posts to associate with analytics
const blogPosts = await prisma.blogPost.findMany({
select: {
id: true,
title: true,
slug: true,
author: true,
publishedAt: true,
}
});
const totalArticleViews = await prisma.analyticsEvent.count({
where: {
type: 'PAGE_VIEW',
path: { startsWith: '/actualites/' },
...currentWhere
}
});
// 4. Fetch Previous Data (for Deltas) // 4. Fetch Previous Data (for Deltas)
let prevTotalEvents = 0; let prevTotalEvents = 0;
let prevUniqueVisitors = 0; let prevUniqueVisitors = 0;
let prevMobileCount = 0; let prevMobileCount = 0;
let prevArticleViews = 0;
if (prevWhere) { if (prevWhere) {
prevTotalEvents = await prisma.analyticsEvent.count({ where: prevWhere }); prevTotalEvents = await prisma.analyticsEvent.count({ where: prevWhere });
@@ -123,6 +196,14 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
_count: { id: true } _count: { id: true }
}); });
prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0; prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0;
prevArticleViews = await prisma.analyticsEvent.count({
where: {
type: 'PAGE_VIEW',
path: { startsWith: '/actualites/' },
...prevWhere
}
});
} }
// 5. Recent Unique IPs // 5. Recent Unique IPs
@@ -156,9 +237,53 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
totalEvents, totalEvents,
uniqueVisitors, uniqueVisitors,
recentIPs, recentIPs,
uniqueVisitorsPerCountry,
totalArticleViews,
articleStats: blogPosts.map(post => {
const paths = [`/actualites/${post.id}`];
if (post.slug) {
paths.push(`/actualites/${post.slug}`);
}
let views = 0;
pageViewsByPath.forEach(item => {
if (paths.includes(item.path)) {
views += item._count.id;
}
});
const uniqueIps = new Set<string>();
uniqueVisitorsRaw.forEach(item => {
if (paths.includes(item.path) && item.ip) {
uniqueIps.add(item.ip);
}
});
const visitors = uniqueIps.size;
let totalDwellTime = 0;
let dwellCount = 0;
dwellTimeByPath.forEach(item => {
if (paths.includes(item.path) && item._avg.value && item._count.id) {
totalDwellTime += item._avg.value * item._count.id;
dwellCount += item._count.id;
}
});
const avgDwellTime = dwellCount > 0 ? Math.round(totalDwellTime / dwellCount) : 0;
return {
id: post.id,
title: post.title,
author: post.author,
publishedAt: post.publishedAt,
views,
visitors,
avgDwellTime
};
}).sort((a, b) => b.views - a.views),
deltas: { deltas: {
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null, events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null, visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
articleViews: prevArticleViews > 0 ? ((totalArticleViews - prevArticleViews) / prevArticleViews) * 100 : null,
mobile: prevTotalEvents > 0 && totalEvents > 0 mobile: prevTotalEvents > 0 && totalEvents > 0
? ((currentMobileCount / totalEvents) - (prevMobileCount / prevTotalEvents)) * 100 ? ((currentMobileCount / totalEvents) - (prevMobileCount / prevTotalEvents)) * 100
: null : null
@@ -169,7 +294,7 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) { export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
const searchParams = await searchParamsPromise; const searchParams = await searchParamsPromise;
const range = searchParams.range || 'week'; const range = searchParams.range || 'week';
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas } = await getMetrics(range, searchParams.from, searchParams.to); const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas, uniqueVisitorsPerCountry, totalArticleViews, articleStats } = await getMetrics(range, searchParams.from, searchParams.to);
const rangeLabels: Record<string, string> = { const rangeLabels: Record<string, string> = {
day: 'Dernières 24h', day: 'Dernières 24h',
@@ -230,20 +355,20 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
</div> </div>
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="card border-t-4 border-indigo-500"> <div className="card border-t-4 border-indigo-500">
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400"> <div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
<Users className="w-6 h-6" /> <Users className="w-6 h-6" />
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-2xl font-bold text-white">{uniqueVisitors}</div> <div className="text-2xl font-bold text-white">{uniqueVisitors.toLocaleString()}</div>
<ComparisonBadge value={deltas.visitors} /> <ComparisonBadge value={deltas.visitors} />
</div> </div>
</div> </div>
<h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3> <h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider"> <p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]} Personnes réelles distinctes
</p> </p>
</div> </div>
@@ -253,13 +378,29 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<Eye className="w-6 h-6" /> <Eye className="w-6 h-6" />
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-2xl font-bold text-white">{totalEvents}</div> <div className="text-2xl font-bold text-white">{totalEvents.toLocaleString()}</div>
<ComparisonBadge value={deltas.events} /> <ComparisonBadge value={deltas.events} />
</div> </div>
</div> </div>
<h3 className="text-slate-400 text-sm font-medium">Événements enregistrés</h3> <h3 className="text-slate-400 text-sm font-medium">Impressions (Pages vues)</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider"> <p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]} Nombre total de fois les pages ont é vues
</p>
</div>
<div className="card border-t-4 border-rose-500">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-400">
<BookOpen className="w-6 h-6" />
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">{totalArticleViews.toLocaleString()}</div>
<ComparisonBadge value={deltas.articleViews} />
</div>
</div>
<h3 className="text-slate-400 text-sm font-medium">Articles consultés</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
Vues totales des articles de blog
</p> </p>
</div> </div>
@@ -275,13 +416,29 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<ComparisonBadge value={deltas.mobile} /> <ComparisonBadge value={deltas.mobile} />
</div> </div>
</div> </div>
<h3 className="text-slate-400 text-sm font-medium">Part du trafic Mobile</h3> <h3 className="text-slate-400 text-sm font-medium">Trafic Mobile</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider"> <p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]} Part des appareils mobiles
</p> </p>
</div> </div>
</div> </div>
{/* World Map Section */}
<AnalyticsMap
data={uniqueVisitorsPerCountry}
title="Répartition Géographique (Visiteurs Uniques)"
/>
{/* Histogram Section */}
<CountryHistogram
title="Comparatif Impressions vs Visiteurs Uniques"
data={topCountries.map(c => ({
country: c.country || 'Inconnu',
impressions: c._count.id,
visitors: uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0
}))}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Countries Table */} {/* Top Countries Table */}
<div className="card p-0 overflow-hidden"> <div className="card p-0 overflow-hidden">
@@ -294,18 +451,28 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider"> <thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr> <tr>
<th className="px-6 py-4">Pays</th> <th className="px-6 py-4">Pays</th>
<th className="px-6 py-4 text-right">Visites</th> <th className="px-6 py-4 text-right">Impressions</th>
<th className="px-6 py-4 text-right">V. Uniques</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800"> <tbody className="divide-y divide-slate-800">
{topCountries.map((c) => ( {topCountries.map((c) => {
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors"> const uniqueCount = uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0;
<td className="px-6 py-4 text-sm text-slate-300 font-bold">{c.country || 'Inconnu'}</td> return (
<td className="px-6 py-4 text-right text-sm font-bold text-white"> <tr key={c.country} className="hover:bg-slate-800/30 transition-colors text-xs">
{c._count.id} <td className="px-6 py-4 text-slate-300 font-bold flex items-center gap-2">
</td> <span className="w-2 h-2 rounded-full bg-indigo-500"></span>
</tr> {c.country || 'Inconnu'}
))} </td>
<td className="px-6 py-4 text-right font-medium text-slate-400">
{c._count.id.toLocaleString()}
</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{uniqueCount.toLocaleString()}
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -380,6 +547,53 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
</table> </table>
</div> </div>
{/* Article Statistics Table */}
<div className="card p-0 overflow-hidden lg:col-span-2">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<BookOpen className="w-5 h-5 text-rose-400" /> Statistiques par article
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">Article</th>
<th className="px-6 py-4">Auteur</th>
<th className="px-6 py-4 text-right">Vues</th>
<th className="px-6 py-4 text-right">V. Uniques</th>
<th className="px-6 py-4 text-right">Temps de lecture moyen</th>
<th className="px-6 py-4 text-right">Date de publication</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{articleStats.map((post) => (
<tr key={post.id} className="hover:bg-slate-800/30 transition-colors text-xs">
<td className="px-6 py-4 font-semibold text-slate-200">
{post.title}
</td>
<td className="px-6 py-4 text-slate-400">
{post.author}
</td>
<td className="px-6 py-4 text-right font-bold text-white">
{post.views.toLocaleString()}
</td>
<td className="px-6 py-4 text-right font-bold text-indigo-400">
{post.visitors.toLocaleString()}
</td>
<td className="px-6 py-4 text-right text-slate-300 font-mono">
{post.avgDwellTime > 0 ? `${post.avgDwellTime}s` : '---'}
</td>
<td className="px-6 py-4 text-right text-slate-400">
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString('fr-FR') : 'Non publié'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Recent IP Connections Table */} {/* Recent IP Connections Table */}
<div className="card p-0 overflow-hidden lg:col-span-2"> <div className="card p-0 overflow-hidden lg:col-span-2">
<div className="p-6 border-b border-slate-800 flex items-center justify-between"> <div className="p-6 border-b border-slate-800 flex items-center justify-between">

View File

@@ -6,14 +6,18 @@ import { LayoutDashboard, ArrowLeft } from 'lucide-react';
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6"> <div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<div className="max-w-md w-full text-center"> <div className="max-w-md w-full text-center space-y-8">
<div className="mb-8"> <div className="relative">
<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"> <h1 className="text-[150px] font-black text-slate-900 leading-none select-none">404</h1>
<span className="text-4xl font-bold text-indigo-400">404</span> <div className="absolute inset-0 flex items-center justify-center">
<span className="text-4xl font-bold text-white tracking-widest uppercase">Perdu ?</span>
</div> </div>
<h1 className="text-3xl font-bold text-white mb-2">Page d'administration introuvable</h1> </div>
<p className="text-slate-400">La ressource que vous recherchez n'existe pas ou a é déplacée.</p>
<div className="space-y-2">
<h2 className="text-2xl font-bold text-white">Cette page n'existe pas</h2>
<p className="text-slate-400">Le contenu que vous recherchez semble avoir é déplacé ou supprimé.</p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -1,4 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export const dynamic = 'force-dynamic';
export default function Home() { export default function Home() {
redirect("/dashboard"); redirect("/dashboard");

View File

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

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import React, { useState, useEffect, useTransition } from 'react'; import React, { useState, useEffect, useTransition } from 'react';
import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase } from 'lucide-react'; import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase, Plus, Trash2, Key, Terminal, Copy, Check } from 'lucide-react';
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings'; import { getSiteSettings, updateSiteSettings, getExternalApiKey } from '@/app/actions/settings';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { getBlogPosts } from '@/app/actions/blog'; import { getBlogPosts } from '@/app/actions/actualites';
import RichTextEditor from '@/components/RichTextEditor'; import RichTextEditor from '@/components/RichTextEditor';
const BUSINESS_CATEGORIES = [ const BUSINESS_CATEGORIES = [
@@ -34,10 +34,10 @@ const BLOG_CATEGORIES = [
"Formation" "Formation"
]; ];
export default function SettingsPage({ export default function SettingsPage({
searchParams searchParams
}: { }: {
searchParams: Promise<{ tab?: string }> searchParams: Promise<{ tab?: string }>
}) { }) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [settings, setSettings] = useState<any>(null); const [settings, setSettings] = useState<any>(null);
@@ -46,27 +46,43 @@ export default function SettingsPage({
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]); const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]); const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]); const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
const [notFoundQuotes, setNotFoundQuotes] = useState<string[]>([]);
const [resetPasswordTemplate, setResetPasswordTemplate] = useState(''); const [resetPasswordTemplate, setResetPasswordTemplate] = useState('');
const [verifyEmailTemplate, setVerifyEmailTemplate] = useState(''); const [verifyEmailTemplate, setVerifyEmailTemplate] = useState('');
const [apiKey, setApiKey] = useState('');
const [copied, setCopied] = useState(false);
const [apiOrigin, setApiOrigin] = useState('https://afroprenariat.com');
// Use a state for the tab if we want it to be fully client-side reactive without full reload // 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 // But since the user wants it "like moderation center", I'll use URL search params
const [currentTab, setCurrentTab] = useState('general'); const [currentTab, setCurrentTab] = useState('general');
useEffect(() => { useEffect(() => {
async function loadData() { async function loadData() {
const [settingsData, postsData] = await Promise.all([ try {
getSiteSettings(), const [settingsData, postsData, apiKeyData] = await Promise.all([
getBlogPosts() getSiteSettings(),
]); getBlogPosts(),
setSettings(settingsData); getExternalApiKey()
setAllPosts(postsData); ]);
setSelectedPostIds(settingsData?.homeBlogIds || []); setSettings(settingsData);
setSelectedHomeCategories(settingsData?.homeCategories || []); setAllPosts(postsData);
setSelectedBlogCategories(settingsData?.homeBlogCategories || []); setApiKey(apiKeyData);
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || ''); if (typeof window !== 'undefined') {
setVerifyEmailTemplate(settingsData?.verifyEmailTemplate || ''); setApiOrigin(window.location.origin);
setLoading(false); }
setSelectedPostIds(settingsData?.homeBlogIds || []);
setSelectedHomeCategories(settingsData?.homeCategories || []);
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
setNotFoundQuotes(settingsData?.notFoundQuotes || ["L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."]);
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(); loadData();
}, []); }, []);
@@ -75,33 +91,35 @@ export default function SettingsPage({
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const data = { const data = {
siteName: formData.get('siteName'), siteName: (formData.get('siteName') ?? settings?.siteName) as string,
siteSlogan: formData.get('siteSlogan'), siteSlogan: (formData.get('siteSlogan') ?? settings?.siteSlogan) as string,
contactEmail: formData.get('contactEmail'), contactEmail: (formData.get('contactEmail') ?? settings?.contactEmail) as string,
contactPhone: formData.get('contactPhone'), contactPhone: (formData.get('contactPhone') ?? settings?.contactPhone) as string,
address: formData.get('address'), address: (formData.get('address') ?? settings?.address) as string,
facebookUrl: formData.get('facebookUrl'), facebookUrl: (formData.get('facebookUrl') ?? settings?.facebookUrl) as string,
twitterUrl: formData.get('twitterUrl'), twitterUrl: (formData.get('twitterUrl') ?? settings?.twitterUrl) as string,
instagramUrl: formData.get('instagramUrl'), instagramUrl: (formData.get('instagramUrl') ?? settings?.instagramUrl) as string,
linkedinUrl: formData.get('linkedinUrl'), linkedinUrl: (formData.get('linkedinUrl') ?? settings?.linkedinUrl) as string,
footerText: formData.get('footerText'), footerText: (formData.get('footerText') ?? settings?.footerText) as string,
homeBlogShow: formData.get('homeBlogShow') === 'on', homeBlogShow: formData.has('homeBlogShow') ? formData.get('homeBlogShow') === 'on' : settings?.homeBlogShow,
homeBlogTitle: formData.get('homeBlogTitle'), homeBlogTitle: (formData.get('homeBlogTitle') ?? settings?.homeBlogTitle) as string,
homeBlogSubtitle: formData.get('homeBlogSubtitle'), homeBlogSubtitle: (formData.get('homeBlogSubtitle') ?? settings?.homeBlogSubtitle) as string,
homeBlogCount: parseInt(formData.get('homeBlogCount') as string || '3'), homeBlogCount: formData.has('homeBlogCount') ? parseInt(formData.get('homeBlogCount') as string || '3') : settings?.homeBlogCount,
homeBlogSelection: formData.get('homeBlogSelection'), homeBlogSelection: (formData.get('homeBlogSelection') ?? settings?.homeBlogSelection) as string,
homeBlogIds: selectedPostIds, homeBlogIds: selectedPostIds,
homeBlogCategories: selectedBlogCategories, homeBlogCategories: selectedBlogCategories,
homeCategories: selectedHomeCategories, homeCategories: selectedHomeCategories,
resetPasswordSubject: formData.get('resetPasswordSubject'), notFoundQuotes: notFoundQuotes.filter(q => q.trim() !== ''),
resetPasswordSubject: (formData.get('resetPasswordSubject') ?? settings?.resetPasswordSubject) as string,
resetPasswordTemplate: resetPasswordTemplate, resetPasswordTemplate: resetPasswordTemplate,
verifyEmailSubject: formData.get('verifyEmailSubject'), verifyEmailSubject: (formData.get('verifyEmailSubject') ?? settings?.verifyEmailSubject) as string,
verifyEmailTemplate: verifyEmailTemplate, verifyEmailTemplate: verifyEmailTemplate,
}; };
startTransition(async () => { startTransition(async () => {
const result = await updateSiteSettings(data); const result = await updateSiteSettings(data);
if (result.success) { if (result.success) {
setSettings(result.settings);
toast.success("Paramètres mis à jour avec succès"); toast.success("Paramètres mis à jour avec succès");
} else { } else {
toast.error("Erreur lors de la mise à jour"); toast.error("Erreur lors de la mise à jour");
@@ -110,23 +128,39 @@ export default function SettingsPage({
}; };
const togglePostId = (id: string) => { const togglePostId = (id: string) => {
setSelectedPostIds(prev => setSelectedPostIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
); );
}; };
const toggleHomeCategory = (cat: string) => { const toggleHomeCategory = (cat: string) => {
setSelectedHomeCategories(prev => setSelectedHomeCategories(prev =>
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat] prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
); );
}; };
const toggleBlogCategory = (cat: string) => { const toggleBlogCategory = (cat: string) => {
setSelectedBlogCategories(prev => setSelectedBlogCategories(prev =>
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat] prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
); );
}; };
const handleAddQuote = () => {
setNotFoundQuotes(prev => [...prev, '']);
};
const handleUpdateQuote = (index: number, value: string) => {
setNotFoundQuotes(prev => {
const updated = [...prev];
updated[index] = value;
return updated;
});
};
const handleRemoveQuote = (index: number) => {
setNotFoundQuotes(prev => prev.filter((_, i) => i !== index));
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center p-20"> <div className="flex items-center justify-center p-20">
@@ -146,19 +180,29 @@ export default function SettingsPage({
{/* Tabs Switcher */} {/* Tabs Switcher */}
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit"> <div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
<button <button
onClick={() => setCurrentTab('general')} type="button"
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'}`} 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" /> <Globe className="w-4 h-4" />
Général Général
</button> </button>
<button <button
onClick={() => setCurrentTab('emails')} type="button"
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'}`} 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" /> <Mail className="w-4 h-4" />
Configuration Mail Configuration Mail
</button>
<button
type="button"
onClick={() => setCurrentTab('api')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'api' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Key className="w-4 h-4" />
Intégration API
</button> </button>
</div> </div>
@@ -174,31 +218,82 @@ export default function SettingsPage({
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du site</label> <label className="text-sm font-medium text-slate-400">Nom du site</label>
<input <input
name="siteName" name="siteName"
defaultValue={settings?.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" 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"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Slogan du site</label> <label className="text-sm font-medium text-slate-400">Slogan du site</label>
<input <input
name="siteSlogan" name="siteSlogan"
defaultValue={settings?.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" 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="md:col-span-2 space-y-2"> <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> <label className="text-sm font-medium text-slate-400">Texte du pied de page (Footer)</label>
<input <input
name="footerText" name="footerText"
defaultValue={settings?.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" 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> </div>
</div> </div>
{/* Section Page 404 - Phrases */}
<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">
<Globe className="w-5 h-5 text-rose-400" />
<h2 className="font-semibold text-white">Phrases de la Page 404</h2>
</div>
<span className="text-xs bg-rose-500/10 text-rose-400 border border-rose-500/20 px-2.5 py-1 rounded-full font-bold">
{notFoundQuotes.length} configurée{notFoundQuotes.length > 1 ? 's' : ''}
</span>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-slate-400">
Configurez ici les différentes phrases ou citations qui s'afficheront de manière aléatoire sur la page d'erreur 404.
</p>
<div className="space-y-3">
{notFoundQuotes.map((quote, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
value={quote}
onChange={(e) => handleUpdateQuote(index, e.target.value)}
placeholder="Saisissez une citation..."
className="flex-grow bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 text-sm"
/>
{notFoundQuotes.length > 1 && (
<button
type="button"
onClick={() => handleRemoveQuote(index)}
className="p-3 bg-slate-950 border border-slate-700 hover:border-rose-500/50 text-slate-400 hover:text-rose-400 rounded-xl transition-all"
title="Supprimer cette phrase"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
<button
type="button"
onClick={handleAddQuote}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 rounded-xl text-sm font-bold transition-all"
>
<Plus className="w-4 h-4" />
Ajouter une phrase
</button>
</div>
</div>
{/* Section Page d'Accueil - Secteurs */} {/* Section Page d'Accueil - Secteurs */}
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden"> <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"> <div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
@@ -213,11 +308,10 @@ export default function SettingsPage({
key={cat} key={cat}
type="button" type="button"
onClick={() => toggleHomeCategory(cat)} onClick={() => toggleHomeCategory(cat)}
className={`text-left p-2 rounded-lg border text-xs transition-all ${ className={`text-left p-2 rounded-lg border text-xs transition-all ${selectedHomeCategories.includes(cat)
selectedHomeCategories.includes(cat) ? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300' : 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500' }`}
}`}
> >
{cat} {cat}
</button> </button>
@@ -234,9 +328,9 @@ export default function SettingsPage({
</div> </div>
<div className="p-6 space-y-6"> <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"> <div className="flex items-center gap-3 p-3 bg-indigo-600/10 border border-indigo-500/20 rounded-xl">
<input <input
type="checkbox" type="checkbox"
name="homeBlogShow" name="homeBlogShow"
id="homeBlogShow" id="homeBlogShow"
defaultChecked={settings?.homeBlogShow} defaultChecked={settings?.homeBlogShow}
className="w-5 h-5 rounded border-slate-700 bg-slate-950 text-indigo-600 focus:ring-indigo-500" className="w-5 h-5 rounded border-slate-700 bg-slate-950 text-indigo-600 focus:ring-indigo-500"
@@ -247,18 +341,18 @@ export default function SettingsPage({
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre de la section</label> <label className="text-sm font-medium text-slate-400">Titre de la section</label>
<input <input
name="homeBlogTitle" name="homeBlogTitle"
defaultValue={settings?.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" 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"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Sous-titre</label> <label className="text-sm font-medium text-slate-400">Sous-titre</label>
<input <input
name="homeBlogSubtitle" name="homeBlogSubtitle"
defaultValue={settings?.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" 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> </div>
@@ -287,12 +381,12 @@ export default function SettingsPage({
<div className="grid grid-cols-1 gap-6"> <div className="grid grid-cols-1 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nombre maximum d'articles</label> <label className="text-sm font-medium text-slate-400">Nombre maximum d'articles</label>
<input <input
type="number" type="number"
name="homeBlogCount" name="homeBlogCount"
defaultValue={settings?.homeBlogCount} defaultValue={settings?.homeBlogCount}
min="1" max="12" 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" 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>
@@ -305,11 +399,10 @@ export default function SettingsPage({
key={post.id} key={post.id}
type="button" type="button"
onClick={() => togglePostId(post.id)} onClick={() => togglePostId(post.id)}
className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${ className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${selectedPostIds.includes(post.id)
selectedPostIds.includes(post.id) ? 'bg-indigo-600/20 border-indigo-500/50 text-white'
? 'bg-indigo-600/20 border-indigo-500/50 text-white' : 'text-slate-400 hover:bg-slate-900'
: 'text-slate-400 hover:bg-slate-900' }`}
}`}
> >
<span>{post.title}</span> <span>{post.title}</span>
{selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</span>} {selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</span>}
@@ -327,11 +420,10 @@ export default function SettingsPage({
key={cat} key={cat}
type="button" type="button"
onClick={() => toggleBlogCategory(cat)} onClick={() => toggleBlogCategory(cat)}
className={`px-4 py-2 rounded-full border text-xs transition-all ${ className={`px-4 py-2 rounded-full border text-xs transition-all ${selectedBlogCategories.includes(cat)
selectedBlogCategories.includes(cat) ? 'bg-purple-600 border-purple-500 text-white'
? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500' }`}
}`}
> >
{cat} {cat}
</button> </button>
@@ -353,10 +445,10 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Email de support</label> <label className="text-sm font-medium text-slate-400">Email de support</label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" /> <Mail className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input <input
name="contactEmail" name="contactEmail"
defaultValue={settings?.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" 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>
@@ -364,10 +456,10 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Téléphone de contact</label> <label className="text-sm font-medium text-slate-400">Téléphone de contact</label>
<div className="relative"> <div className="relative">
<Phone className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" /> <Phone className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input <input
name="contactPhone" name="contactPhone"
defaultValue={settings?.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" 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>
@@ -375,10 +467,10 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Adresse physique</label> <label className="text-sm font-medium text-slate-400">Adresse physique</label>
<div className="relative"> <div className="relative">
<MapPin className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" /> <MapPin className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input <input
name="address" name="address"
defaultValue={settings?.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" 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>
@@ -396,11 +488,11 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Facebook URL</label> <label className="text-sm font-medium text-slate-400">Facebook URL</label>
<div className="relative"> <div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" /> <Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input <input
name="facebookUrl" name="facebookUrl"
defaultValue={settings?.facebookUrl} defaultValue={settings?.facebookUrl}
placeholder="https://facebook.com/..." 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" 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>
@@ -408,11 +500,11 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label> <label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
<div className="relative"> <div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" /> <Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input <input
name="twitterUrl" name="twitterUrl"
defaultValue={settings?.twitterUrl} defaultValue={settings?.twitterUrl}
placeholder="https://twitter.com/..." 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" 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>
@@ -420,11 +512,11 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Instagram URL</label> <label className="text-sm font-medium text-slate-400">Instagram URL</label>
<div className="relative"> <div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" /> <Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input <input
name="instagramUrl" name="instagramUrl"
defaultValue={settings?.instagramUrl} defaultValue={settings?.instagramUrl}
placeholder="https://instagram.com/..." 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" 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>
@@ -432,11 +524,11 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">LinkedIn URL</label> <label className="text-sm font-medium text-slate-400">LinkedIn URL</label>
<div className="relative"> <div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" /> <Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input <input
name="linkedinUrl" name="linkedinUrl"
defaultValue={settings?.linkedinUrl} defaultValue={settings?.linkedinUrl}
placeholder="https://linkedin.com/in/..." 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" 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>
@@ -459,17 +551,17 @@ export default function SettingsPage({
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">Réinitialisation de mot de passe</h3> <h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">Réinitialisation de mot de passe</h3>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Objet de l'email</label> <label className="text-sm font-medium text-slate-400">Objet de l'email</label>
<input <input
name="resetPasswordSubject" name="resetPasswordSubject"
defaultValue={settings?.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" 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"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Modèle d'email</label> <label className="text-sm font-medium text-slate-400">Modèle d'email</label>
<RichTextEditor <RichTextEditor
value={resetPasswordTemplate} value={resetPasswordTemplate}
onChange={setResetPasswordTemplate} onChange={setResetPasswordTemplate}
placeholder="Contenu de l'email..." placeholder="Contenu de l'email..."
/> />
<p className="text-[10px] text-slate-500"> <p className="text-[10px] text-slate-500">
@@ -485,17 +577,17 @@ export default function SettingsPage({
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider">Vérification de compte</h3> <h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider">Vérification de compte</h3>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Objet de l'email</label> <label className="text-sm font-medium text-slate-400">Objet de l'email</label>
<input <input
name="verifyEmailSubject" name="verifyEmailSubject"
defaultValue={settings?.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" 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"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Modèle d'email</label> <label className="text-sm font-medium text-slate-400">Modèle d'email</label>
<RichTextEditor <RichTextEditor
value={verifyEmailTemplate} value={verifyEmailTemplate}
onChange={setVerifyEmailTemplate} onChange={setVerifyEmailTemplate}
placeholder="Contenu de l'email..." placeholder="Contenu de l'email..."
/> />
<p className="text-[10px] text-slate-500"> <p className="text-[10px] text-slate-500">
@@ -508,20 +600,120 @@ export default function SettingsPage({
</div> </div>
)} )}
<div className="flex justify-end"> {currentTab === 'api' && (
<button <div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
type="submit" <div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
disabled={isPending} <div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white px-8 py-3 rounded-xl font-bold transition-all shadow-lg flex items-center gap-2" <Key className="w-5 h-5 text-amber-400" />
> <h2 className="font-semibold text-white">Clé d'API Externe</h2>
{isPending ? ( </div>
<Loader2 className="w-5 h-5 animate-spin" /> <div className="p-6 space-y-4">
) : ( <p className="text-sm text-slate-400">
<Save className="w-5 h-5" /> Utilisez cette clé API pour authentifier vos requêtes lors de la création d'articles depuis un système externe.
)} </p>
Enregistrer les modifications
</button> <div className="flex items-center gap-2 max-w-xl">
</div> <div className="flex-grow bg-slate-950 border border-slate-700 rounded-xl p-3 text-white font-mono text-sm overflow-x-auto select-all">
{apiKey || "Chargement de la clé..."}
</div>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(apiKey);
setCopied(true);
toast.success("Clé API copiée !");
setTimeout(() => setCopied(false), 2000);
}}
className="p-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl transition-all flex items-center gap-2 font-semibold text-sm cursor-pointer shrink-0"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? "Copié" : "Copier"}
</button>
</div>
<p className="text-[11px] text-slate-500">
Gardez cette clé confidentielle. Ne la partagez pas dans des environnements publics ou côté client.
</p>
</div>
</div>
<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">
<Terminal className="w-5 h-5 text-indigo-400" />
<h2 className="font-semibold text-white">Documentation de l'API de Création d'Articles</h2>
</div>
<div className="p-6 space-y-6">
<div>
<h3 className="text-sm font-bold text-slate-300 mb-2">Endpoint</h3>
<div className="bg-slate-950 border border-slate-700 rounded-xl p-3 flex items-center gap-2 font-mono text-xs text-white">
<span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded font-bold">POST</span>
<span className="text-slate-300">{apiOrigin}/api/external/articles</span>
</div>
</div>
<div>
<h3 className="text-sm font-bold text-slate-300 mb-2">Headers requis</h3>
<div className="bg-slate-950 border border-slate-700 rounded-xl p-4 font-mono text-xs text-slate-300 space-y-1">
<div><span className="text-indigo-400">Content-Type</span>: application/json</div>
<div><span className="text-indigo-400">x-api-key</span>: <span className="text-amber-400">{"{VOTRE_CLE_API}"}</span></div>
</div>
</div>
<div>
<h3 className="text-sm font-bold text-slate-300 mb-2">Corps de la requête (JSON)</h3>
<pre className="bg-slate-950 border border-slate-700 rounded-xl p-4 font-mono text-xs text-slate-300 overflow-x-auto">
{`{
"title": "Un nouvel article sur l'écosystème",
"excerpt": "Le résumé de l'article visible sur la liste.",
"content": "<h1>Contenu de l'article</h1><p>Corps de l'article en HTML ou texte brut.</p>",
"author": "Afropreneur API",
"imageUrl": "https://images.unsplash.com/photo-1516321318423-f06f85e504b3",
"type": "Actualité",
"tags": ["Technologie", "Innovation"],
"status": "PUBLISHED",
"publishedAt": "${new Date().toISOString()}"
}`}
</pre>
</div>
<div>
<h3 className="text-sm font-bold text-slate-300 mb-2">Exemple de requête (cURL)</h3>
<pre className="bg-slate-950 border border-slate-700 rounded-xl p-4 font-mono text-xs text-slate-300 overflow-x-auto whitespace-pre-wrap">
{`curl -X POST "${apiOrigin}/api/external/articles" \\
-H "Content-Type: application/json" \\
-H "x-api-key: ${apiKey || "VOTRE_CLE_API"}" \\
-d '{
"title": "Un nouvel article sur l ecosystème",
"excerpt": "Le résumé de l article.",
"content": "Le corps de l article.",
"author": "Afropreneur API",
"imageUrl": "https://images.unsplash.com/photo-1516321318423-f06f85e504b3",
"type": "Actualité",
"tags": ["Tech", "Startup"],
"status": "PUBLISHED"
}'`}
</pre>
</div>
</div>
</div>
</div>
)}
{currentTab !== 'api' && (
<div className="flex justify-end">
<button
type="submit"
disabled={isPending}
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white px-8 py-3 rounded-xl font-bold transition-all shadow-lg flex items-center gap-2"
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Save className="w-5 h-5" />
)}
Enregistrer les modifications
</button>
</div>
)}
</form> </form>
</div> </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

@@ -2,13 +2,15 @@ import { prisma } from '@/lib/prisma';
import { getClients } from "@/app/actions/user"; import { getClients } from "@/app/actions/user";
import ToggleVerifyButton from '@/components/ToggleVerifyButton'; import ToggleVerifyButton from '@/components/ToggleVerifyButton';
import FeaturedModal from '@/components/FeaturedModal'; import FeaturedModal from '@/components/FeaturedModal';
import ToggleHomeFeatureButton from '@/components/ToggleHomeFeatureButton';
import EntrepreneurActions from '@/components/EntrepreneurActions'; import EntrepreneurActions from '@/components/EntrepreneurActions';
import PlanSelector from '@/components/PlanSelector'; import PlanSelector from '@/components/PlanSelector';
import ClientActions from "@/components/ClientActions"; import ClientActions from "@/components/ClientActions";
import ManualVerifyButton from "@/components/ManualVerifyButton"; import ManualVerifyButton from "@/components/ManualVerifyButton";
import { ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react'; import { Trash2, ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
// Force Turbopack native load
async function getBusinesses() { async function getBusinesses() {
return await prisma.business.findMany({ return await prisma.business.findMany({
where: { where: {
@@ -87,30 +89,32 @@ export default async function UsersPage({
)} )}
<div className="card overflow-hidden p-0"> <div className="card overflow-hidden p-0">
<table className="data-table"> <div className="overflow-x-auto">
<table className="data-table min-w-[1200px]">
<thead> <thead>
<tr> <tr>
<th>Entreprise</th> <th className="sticky left-0 z-30 bg-[#1e293b] border-r border-slate-700">Entreprise</th>
<th>Catégorie</th> <th>Catégorie</th>
<th>Propriétaire</th> <th>Propriétaire</th>
<th>Email</th> <th>Email</th>
<th>Statut</th> <th>Statut</th>
<th className="text-center">Forfait</th> <th className="text-center">Forfait</th>
<th className="text-center">Mise en avant</th> <th className="text-center whitespace-nowrap">À la une (Accueil)</th>
<th className="text-center whitespace-nowrap">Afroshine (Annuaire)</th>
<th className="text-right">Action</th> <th className="text-right">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{businesses.length === 0 ? ( {businesses.length === 0 ? (
<tr> <tr>
<td colSpan={8} className="text-center py-10 text-slate-500"> <td colSpan={9} className="text-center py-10 text-slate-500">
Aucun entrepreneur trouvé. Aucun entrepreneur trouvé.
</td> </td>
</tr> </tr>
) : ( ) : (
businesses.map((business: any) => ( businesses.map((business: any) => (
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}> <tr key={business.id} className={`transition-colors ${business.owner?.deletedAt ? 'bg-red-500/5' : (business.isSuspended || business.owner?.isSuspended) ? 'grayscale-[0.5]' : ''}`}>
<td> <td className="sticky left-0 z-20 bg-[#1e293b] border-r border-slate-700">
<div className="flex items-center gap-3"> <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"> <div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
{business.logoUrl ? ( {business.logoUrl ? (
@@ -129,11 +133,11 @@ export default async function UsersPage({
<span className="text-sm text-slate-300">{business.category}</span> <span className="text-sm text-slate-300">{business.category}</span>
</td> </td>
<td> <td>
<div className="text-sm text-white">{business.owner.name}</div> <div className="text-sm text-white">{business.owner?.name || 'Inconnu'}</div>
<div className="text-xs text-slate-500">{business.owner.email}</div> <div className="text-xs text-slate-500">{business.owner?.email || 'N/A'}</div>
</td> </td>
<td> <td>
{business.owner.emailVerified ? ( {business.owner?.emailVerified ? (
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase"> <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é <MailCheck className="w-3.5 h-3.5" /> Vérifié
</span> </span>
@@ -142,13 +146,17 @@ export default async function UsersPage({
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase"> <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é <MailX className="w-3.5 h-3.5" /> Non vérifié
</span> </span>
<ManualVerifyButton userId={business.owner.id} /> {business.owner?.id && <ManualVerifyButton userId={business.owner.id} />}
</div> </div>
)} )}
</td> </td>
<td> <td>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{business.owner?.isSuspended ? ( {business.owner?.deletedAt ? (
<span className="badge bg-red-500/10 text-red-500 border border-red-500/20 flex items-center gap-1">
<Trash2 className="w-3 h-3" /> Suppression en cours
</span>
) : business.owner?.isSuspended ? (
<span className="badge badge-error flex items-center gap-1"> <span className="badge badge-error flex items-center gap-1">
<ShieldX className="w-3 h-3" /> Compte Suspendu <ShieldX className="w-3 h-3" /> Compte Suspendu
</span> </span>
@@ -166,6 +174,9 @@ export default async function UsersPage({
<td className="text-center"> <td className="text-center">
<PlanSelector businessId={business.id} currentPlan={business.plan} /> <PlanSelector businessId={business.id} currentPlan={business.plan} />
</td> </td>
<td className="text-center">
<ToggleHomeFeatureButton id={business.id} isHomeFeatured={!!business.isHomeFeatured} />
</td>
<td className="text-center"> <td className="text-center">
<FeaturedModal business={business} /> <FeaturedModal business={business} />
</td> </td>
@@ -176,8 +187,8 @@ export default async function UsersPage({
<EntrepreneurActions <EntrepreneurActions
businessId={business.id} businessId={business.id}
businessName={business.name} businessName={business.name}
userId={business.owner.id} userId={business.owner?.id || ''}
userName={business.owner.name} userName={business.owner?.name || 'Inconnu'}
isBusinessSuspended={!!business.isSuspended} isBusinessSuspended={!!business.isSuspended}
isUserSuspended={!!business.owner?.isSuspended} isUserSuspended={!!business.owner?.isSuspended}
metaTitle={business.metaTitle} metaTitle={business.metaTitle}
@@ -190,15 +201,16 @@ export default async function UsersPage({
)} )}
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
</div> </div>
) : ( ) : (
<div className="card overflow-hidden"> <div className="card overflow-hidden p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="data-table"> <table className="data-table min-w-[1000px]">
<thead> <thead>
<tr> <tr>
<th>Utilisateur</th> <th className="sticky left-0 z-30 bg-[#1e293b] border-r border-slate-700">Utilisateur</th>
<th>Contact</th> <th>Contact</th>
<th>Vérification</th> <th>Vérification</th>
<th>Localisation</th> <th>Localisation</th>
@@ -218,8 +230,8 @@ export default async function UsersPage({
</tr> </tr>
) : ( ) : (
clients.map((client: any) => ( clients.map((client: any) => (
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}> <tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.deletedAt ? 'bg-red-500/5' : client.isSuspended ? 'grayscale-[0.5]' : ''}`}>
<td className="py-4"> <td className="sticky left-0 z-20 bg-[#1e293b] border-r border-slate-700 py-4">
<div className="flex items-center gap-3"> <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"> <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 ? ( {client.avatar ? (
@@ -270,7 +282,11 @@ export default async function UsersPage({
</td> </td>
<td> <td>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{client.isSuspended ? ( {client.deletedAt ? (
<span className="badge bg-red-500/10 text-red-500 border border-red-500/20 flex items-center gap-1">
<Trash2 className="w-3 h-3" /> Suppression en cours
</span>
) : client.isSuspended ? (
<> <>
<span className="badge badge-error">Compte Suspendu</span> <span className="badge badge-error">Compte Suspendu</span>
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}> <span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>

View File

@@ -0,0 +1,434 @@
"use client";
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/actualites';
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive, Link as LinkIcon, Plus, Trash2, FileJson } 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?: {
id: string;
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
slug?: string | null;
tags?: string[];
metaTitle?: string | null;
metaDescription?: string | null;
publishedAt?: Date | string | null;
status?: ContentStatus;
coverPosition?: string | null;
coverZoom?: number | null;
sources?: any;
};
}
export default function BlogForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
// JSON prefill state
const [currentData, setCurrentData] = useState<any>(initialData || {});
const [jsonString, setJsonString] = useState('');
const [showJsonInput, setShowJsonInput] = useState(false);
const [jsonError, setJsonError] = useState<string | null>(null);
// Form input states
const [content, setContent] = useState(initialData?.content || '');
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
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 [sources, setSources] = useState<{ title: string; url: string }[]>(
Array.isArray(initialData?.sources) ? initialData.sources : []
);
const isPublished = new Date(publishedAtValue) <= new Date();
useEffect(() => {
getAllTags().then(setAllExistingTags);
}, []);
const handleApplyJson = () => {
try {
if (!jsonString.trim()) {
throw new Error("Veuillez coller un code JSON.");
}
const parsed = JSON.parse(jsonString);
// Extract new combined tags
const newTags = Array.isArray(parsed.tags) ? [...parsed.tags] : [...tags];
if (parsed.type && !newTags.includes(parsed.type)) {
newTags.push(parsed.type);
}
// Update states to prefill rich components
if (parsed.content) setContent(parsed.content);
if (parsed.imageUrl) setImageUrl(parsed.imageUrl);
setTags(newTags);
if (Array.isArray(parsed.sources)) {
setSources(parsed.sources);
}
// Update currentData to reset defaultValues on native inputs via form key remount
setCurrentData({
...currentData,
title: parsed.title || currentData.title || '',
excerpt: parsed.excerpt || currentData.excerpt || '',
author: parsed.author || currentData.author || 'Rédaction Afrohub',
slug: parsed.slug || currentData.slug || '',
metaTitle: parsed.metaTitle || currentData.metaTitle || '',
metaDescription: parsed.metaDescription || currentData.metaDescription || '',
_prefillKey: Date.now() // Force form remount
});
setJsonError(null);
setShowJsonInput(false);
setJsonString('');
toast.success("Champs pré-remplis avec le JSON !");
} catch (err: any) {
setJsonError("Format JSON invalide. Vérifiez la syntaxe : " + (err.message || ''));
}
};
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: 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,
coverPosition: coverPosition,
coverZoom: coverZoom,
sources: sources,
};
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 créé avec succès");
router.push('/actualites');
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="/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">
{initialData ? 'Modifier l\'article' : 'Nouvel Article'}
</h1>
</div>
</div>
{/* PREFILL JSON SECTION */}
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 mb-8 shadow-md">
<button
type="button"
onClick={() => setShowJsonInput(!showJsonInput)}
className="flex items-center justify-between w-full text-left font-bold text-sm text-brand-400 hover:text-brand-300 transition-colors"
>
<span className="flex items-center gap-2">
<FileJson className="w-5 h-5 text-indigo-400" />
Importer JSON (Pré-remplir les champs automatiquement)
</span>
<span className="text-xs bg-slate-800 px-2.5 py-1 rounded text-slate-400 border border-slate-700">
{showJsonInput ? 'Masquer' : 'Déplier'}
</span>
</button>
{showJsonInput && (
<div className="mt-4 pt-4 border-t border-slate-800 space-y-3 animate-in fade-in duration-200">
<p className="text-xs text-slate-400">
Collez le code JSON généré par Gemini pour remplir instantanément le titre, l'extrait, le contenu HTML, l'auteur, l'image, le slug, les tags SEO, et les sources.
</p>
{jsonError && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 p-2.5 rounded-lg font-medium">
{jsonError}
</div>
)}
<textarea
value={jsonString}
onChange={(e) => setJsonString(e.target.value)}
placeholder={'{\n "title": "Les 5 tendances de la Tech Africaine en 2026",\n "excerpt": "Découvrez comment l\'intelligence artificielle...",\n "content": "<p>L\'année 2026 marque un tournant...</p>",\n "author": "Rédaction Afrohub",\n "imageUrl": "https://images.unsplash.com/photo-...",\n "slug": "tendances-tech-afrique-2026",\n "tags": ["Technologie", "IA", "FinTech"],\n "metaTitle": "Les 5 tendances Tech en Afrique - Afrohub",\n "metaDescription": "Analyse complète des tendances de l\'écosystème tech...",\n "sources": [\n { "title": "Rapport BAD 2026", "url": "https://afdb.org" }\n ]\n}'}
rows={8}
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-xs font-mono text-emerald-400 focus:outline-none focus:border-indigo-500"
spellCheck={false}
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setJsonString('{\n "title": "Les 5 tendances de la Tech Africaine en 2026",\n "excerpt": "Découvrez comment l\'intelligence artificielle et la FinTech redéfinissent l\'écosystème entrepreneurial sur le continent cette année.",\n "content": "<p>L\'année 2026 marque un tournant décisif. Les startups...</p><h3>L\'IA au service de l\'agriculture</h3><p>De nouvelles solutions émergent...</p>",\n "author": "Rédaction Afrohub",\n "imageUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&q=80&w=1200",\n "slug": "tendances-tech-afrique-2026",\n "tags": ["Technologie", "IA", "FinTech"],\n "metaTitle": "Les 5 tendances Tech en Afrique - Afrohub",\n "metaDescription": "Analyse complète des tendances de l\'écosystème tech et des financements sur le continent africain en 2026.",\n "sources": [\n { "title": "Rapport BAD 2026", "url": "https://afdb.org" },\n { "title": "Jeune Afrique", "url": "https://jeuneafrique.com" }\n ]\n}');
}}
className="px-3 py-1.5 text-xs text-slate-400 hover:text-white transition-colors"
>
Exemple complet
</button>
<button
type="button"
onClick={handleApplyJson}
className="px-4 py-1.5 text-xs font-bold bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors shadow-sm"
>
Appliquer les données
</button>
</div>
</div>
)}
</div>
<form key={currentData._prefillKey || '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">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={currentData.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">
<label className="text-sm font-medium text-slate-400">Titre</label>
<input
name="title"
defaultValue={currentData.title}
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="Ex: L'essor de la Tech en Afrique"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Auteur</label>
<input
name="author"
defaultValue={currentData.author || (initialData ? undefined : 'Rédaction Afrohub')}
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="Ex: Jean Dupont"
/>
</div>
</div>
<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"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
</label>
<div className="relative">
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
<input
name="publishedAt"
type="datetime-local"
value={publishedAtValue}
onChange={(e) => setPublishedAtValue(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
<textarea
name="excerpt"
defaultValue={currentData.excerpt}
required
rows={2}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Un court résumé de l'article..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Rédigez votre article ici..."
/>
</div>
{/* SOURCES SECTION */}
<div className="pt-6 border-t border-slate-800">
<div className="flex items-center justify-between mb-4">
<label className="text-sm font-medium text-slate-400 flex items-center gap-2">
<LinkIcon className="w-4 h-4 text-brand-500" />
Sources & Liens externes
</label>
<button
type="button"
onClick={() => setSources([...sources, { title: '', url: '' }])}
className="text-xs font-bold text-brand-500 hover:text-brand-400 flex items-center gap-1 bg-brand-500/10 px-3 py-1.5 rounded-lg transition-colors"
>
<Plus className="w-3 h-3" />
Ajouter une source
</button>
</div>
<div className="space-y-3">
{sources.map((source, index) => (
<div key={index} className="flex gap-3 animate-in fade-in slide-in-from-top-1 duration-200">
<input
placeholder="Nom de la source (ex: Le Monde)"
value={source.title}
onChange={(e) => {
const newSources = [...sources];
newSources[index].title = e.target.value;
setSources(newSources);
}}
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-indigo-500"
/>
<input
placeholder="URL (ex: https://...)"
value={source.url}
onChange={(e) => {
const newSources = [...sources];
newSources[index].url = e.target.value;
setSources(newSources);
}}
className="flex-2 bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-indigo-500"
/>
<button
type="button"
onClick={() => setSources(sources.filter((_, i) => i !== index))}
className="p-2.5 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
))}
{sources.length === 0 && (
<p className="text-xs text-slate-600 italic">Aucune source ajoutée pour cet article.</p>
)}
</div>
</div>
</div>
{/* SEO SECTION */}
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</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">Slug (URL personnalisée)</label>
<input
name="slug"
defaultValue={currentData.slug || ''}
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: lessor-de-la-tech-afrique"
/>
<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">
<TagInput
value={tags}
onChange={setTags}
suggestions={allExistingTags}
label="Mots-clés (tags)"
placeholder="tech, innovation, afrique..."
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input
name="metaTitle"
defaultValue={currentData.metaTitle || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Titre optimisé pour les moteurs de recherche"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea
name="metaDescription"
defaultValue={currentData.metaDescription || ''}
rows={3}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Description optimisée pour les moteurs de recherche (max 160 caractères)..."
/>
</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 l\'article'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,210 @@
"use client";
import React, { useMemo } from "react";
import {
ComposableMap,
Geographies,
Geography,
Sphere,
Graticule,
ZoomableGroup
} from "react-simple-maps";
import { scaleLinear } from "d3-scale";
import { ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
// URL for the world map TopoJSON
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
interface AnalyticsMapProps {
data: { country: string; count: number }[];
title: string;
}
// Simple mapping for common countries to ISO Alpha-3 codes used by world-atlas
// In a real app, this should be handled at the tracking level or via a robust library
const countryToISO: Record<string, string> = {
"France": "FRA",
"United States": "USA",
"Canada": "CAN",
"United Kingdom": "GBR",
"Germany": "DEU",
"Ivory Coast": "CIV",
"Côte d'Ivoire": "CIV",
"Senegal": "SEN",
"Sénégal": "SEN",
"Cameroon": "CMR",
"Cameroun": "CMR",
"Mali": "MLI",
"Benin": "BEN",
"Bénin": "BEN",
"Togo": "TGO",
"Gabon": "GAB",
"Congo": "COG",
"DR Congo": "COD",
"Nigeria": "NGA",
"Ghana": "GHA",
"Morocco": "MAR",
"Maroc": "MAR",
"Algeria": "DZA",
"Algérie": "DZA",
"Tunisia": "TUN",
"Tunisie": "TUN",
"Belgium": "BEL",
"Belgique": "BEL",
"Switzerland": "CHE",
"Suisse": "CHE",
"Spain": "ESP",
"Espagne": "ESP",
"Italy": "ITA",
"Italie": "ITA",
};
export default function AnalyticsMap({ data, title }: AnalyticsMapProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const maxCount = useMemo(() => {
if (data.length === 0) return 1;
return Math.max(...data.map((d) => d.count));
}, [data]);
const colorScale = scaleLinear<string>()
.domain([0, maxCount])
.range(["#1e293b", "#6366f1"]);
const mappedData = useMemo(() => {
const map: Record<string, number> = {};
data.forEach((d) => {
const iso = countryToISO[d.country] || d.country; // Fallback to name if not in map
map[iso] = d.count;
});
return map;
}, [data]);
const [position, setPosition] = React.useState({ coordinates: [0, 0], zoom: 1 });
function handleZoomIn() {
if (position.zoom >= 4) return;
setPosition((pos) => ({ ...pos, zoom: pos.zoom * 1.5 }));
}
function handleZoomOut() {
if (position.zoom <= 1) return;
setPosition((pos) => ({ ...pos, zoom: pos.zoom / 1.5 }));
}
function handleReset() {
setPosition({ coordinates: [0, 0], zoom: 1 });
}
function handleMoveEnd(position: { coordinates: [number, number]; zoom: number }) {
setPosition(position);
}
return (
<div className="card p-0 overflow-hidden bg-slate-900/50 border border-slate-800 relative group">
<div className="p-6 border-b border-slate-800 flex items-center justify-between mb-2">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
{title}
</h2>
{/* Zoom Controls */}
<div className="flex items-center gap-2 bg-slate-800 p-1.5 rounded-lg border border-slate-700">
<button
onClick={handleZoomIn}
className="p-1.5 hover:bg-slate-700 rounded text-slate-300 hover:text-white transition-colors"
title="Zoomer"
>
<ZoomIn className="w-4 h-4" />
</button>
<button
onClick={handleZoomOut}
className="p-1.5 hover:bg-slate-700 rounded text-slate-300 hover:text-white transition-colors"
title="Dézoomer"
>
<ZoomOut className="w-4 h-4" />
</button>
<div className="w-px h-4 bg-slate-700 mx-1" />
<button
onClick={handleReset}
className="p-1.5 hover:bg-slate-700 rounded text-slate-300 hover:text-white transition-colors"
title="Réinitialiser la vue"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-4 flex justify-center bg-slate-950/20 h-[600px] items-center">
{!mounted ? (
<div className="flex flex-col items-center gap-4 animate-pulse">
<div className="w-16 h-16 border-4 border-indigo-500/20 border-t-indigo-500 rounded-full animate-spin"></div>
<p className="text-slate-500 text-xs font-bold uppercase tracking-widest">Chargement de la carte...</p>
</div>
) : (
<ComposableMap
projectionConfig={{
rotate: [-10, 0, 0],
scale: 147
}}
width={800}
height={600}
style={{ width: "100%", height: "100%" }}
>
<ZoomableGroup
zoom={position.zoom}
center={position.coordinates as [number, number]}
onMoveEnd={handleMoveEnd}
>
<Sphere stroke="#334155" strokeWidth={0.5} id="sphere" fill="transparent" />
<Graticule stroke="#334155" strokeWidth={0.5} />
{data.length > 0 && (
<Geographies geography={geoUrl}>
{({ geographies }) =>
geographies.map((geo) => {
const countryName = geo.properties.name;
const countryId = geo.id;
const countryISO = geo.properties.iso_a3;
const count = mappedData[countryName] || mappedData[countryISO] || mappedData[countryId] || 0;
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={count > 0 ? colorScale(count) : "#0f172a"}
stroke="#1e293b"
strokeWidth={0.5}
style={{
default: { outline: "none" },
hover: { fill: "#4f46e5", outline: "none", cursor: "pointer" },
pressed: { outline: "none" }
}}
/>
);
})
}
</Geographies>
)}
</ZoomableGroup>
</ComposableMap>
)}
</div>
{/* Legend / Instructions */}
<div className="absolute bottom-4 left-4 bg-slate-900/80 backdrop-blur-md border border-slate-700 p-3 rounded-lg text-[10px] text-slate-400 uppercase font-bold tracking-widest pointer-events-none">
<div className="flex items-center gap-2 mb-2">
<span className="w-3 h-3 rounded bg-[#0f172a] border border-slate-700"></span>
<span>Pas de trafic</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]"></span>
<span>Trafic identifié</span>
</div>
</div>
</div>
);
}

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

@@ -1,257 +0,0 @@
"use client";
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
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?: {
id: string;
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
slug?: string | null;
tags?: string[];
metaTitle?: string | null;
metaDescription?: string | null;
publishedAt?: Date | string | null;
status?: ContentStatus;
};
}
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: 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 créé avec succès");
router.push('/blog');
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="/blog" 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 l\'article' : 'Nouvel Article'}
</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">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">
<label className="text-sm font-medium text-slate-400">Titre</label>
<input
name="title"
defaultValue={initialData?.title}
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="Ex: L'essor de la Tech en Afrique"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Auteur</label>
<input
name="author"
defaultValue={initialData?.author}
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="Ex: Jean Dupont"
/>
</div>
</div>
<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">
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
<textarea
name="excerpt"
defaultValue={initialData?.excerpt}
required
rows={2}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Un court résumé de l'article..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Rédigez votre article ici..."
/>
</div>
</div>
{/* SEO SECTION */}
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</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">Slug (URL personnalisée)</label>
<input
name="slug"
defaultValue={initialData?.slug || ''}
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: lessor-de-la-tech-afrique"
/>
<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">
<TagInput
value={tags}
onChange={setTags}
suggestions={allExistingTags}
label="Mots-clés (tags)"
placeholder="tech, innovation, afrique..."
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input
name="metaTitle"
defaultValue={initialData?.metaTitle || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Titre optimisé pour les moteurs de recherche"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea
name="metaDescription"
defaultValue={initialData?.metaDescription || ''}
rows={3}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Description optimisée pour les moteurs de recherche (max 160 caractères)..."
/>
</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 l\'article'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useState, useTransition } from 'react'; import React, { useState, useEffect, useTransition } from 'react';
import { createPortal } from 'react-dom';
import { X, Save, Loader2, Globe } from 'lucide-react'; import { X, Save, Loader2, Globe } from 'lucide-react';
import { updateBusinessSeo } from '@/app/actions/business'; import { updateBusinessSeo } from '@/app/actions/business';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@@ -20,8 +21,14 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [metaTitle, setMetaTitle] = useState(business.metaTitle || ''); const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
const [metaDescription, setMetaDescription] = useState(business.metaDescription || ''); const [metaDescription, setMetaDescription] = useState(business.metaDescription || '');
const [mounted, setMounted] = useState(false);
if (!isOpen) return null; useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!isOpen || !mounted) return null;
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -39,8 +46,8 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
}); });
}; };
return ( const modalContent = (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} /> <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="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
@@ -117,4 +124,6 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
</div> </div>
</div> </div>
); );
return createPortal(modalContent, document.body);
} }

View File

@@ -5,6 +5,7 @@ import { ShieldAlert, ShieldCheck } from 'lucide-react';
import { suspendUser, unsuspendUser } from '@/app/actions/suspension'; import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal'; import SuspensionModal from './SuspensionModal';
import DeleteAccountButton from './DeleteAccountButton';
interface ClientActionsProps { interface ClientActionsProps {
userId: string; userId: string;
@@ -62,6 +63,10 @@ export default function ClientActions({ userId, userName, isSuspended }: ClientA
title="Suspendre le compte" title="Suspendre le compte"
subtitle={`Voulez-vous suspendre le compte de ${userName} ? L'utilisateur sera bloqué et recevra votre message.`} subtitle={`Voulez-vous suspendre le compte de ${userName} ? L'utilisateur sera bloqué et recevra votre message.`}
/> />
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
<DeleteAccountButton userId={userId} userName={userName} />
</> </>
); );
} }

View File

@@ -0,0 +1,107 @@
"use client";
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell
} from 'recharts';
interface CountryHistogramProps {
data: { country: string; impressions: number; visitors: number }[];
title: string;
}
export default function CountryHistogram({ data, title }: CountryHistogramProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
// Take top 10 for readability
const chartData = data.slice(0, 10).map(item => ({
name: item.country || 'Inconnu',
"Impressions": item.impressions,
"V. Uniques": item.visitors
}));
return (
<div className="card bg-slate-900/50 border border-slate-800 p-6">
<h2 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
{title}
</h2>
<div className="h-[400px] w-full flex items-center justify-center">
{!mounted ? (
<p className="text-slate-500 text-xs font-bold uppercase tracking-widest animate-pulse">Chargement du graphique...</p>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis
dataKey="name"
stroke="#64748b"
fontSize={10}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis
stroke="#64748b"
fontSize={10}
tickLine={false}
axisLine={false}
tickFormatter={(value) => value.toLocaleString()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '8px',
fontSize: '12px'
}}
itemStyle={{ padding: '2px 0' }}
cursor={{ fill: 'rgba(255, 255, 255, 0.05)' }}
/>
<Legend
verticalAlign="top"
align="right"
iconType="circle"
wrapperStyle={{ paddingBottom: '20px', fontSize: '10px', textTransform: 'uppercase', fontWeight: 'bold' }}
/>
<Bar
dataKey="Impressions"
fill="#6366f1"
radius={[4, 4, 0, 0]}
barSize={20}
animationDuration={1500}
/>
<Bar
dataKey="V. Uniques"
fill="#10b981"
radius={[4, 4, 0, 0]}
barSize={20}
animationDuration={1500}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { UserX, Loader2 } from 'lucide-react';
import { scheduleUserDeletion } from '@/app/actions/user';
import { toast } from 'react-hot-toast';
interface DeleteAccountButtonProps {
userId: string;
userName: string;
}
export default function DeleteAccountButton({ userId, userName }: DeleteAccountButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [reason, setReason] = useState("Non-respect des conditions générales d'utilisation");
const [customReason, setCustomReason] = useState("");
const [delay, setDelay] = useState(30);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
const reasons = [
"Non-respect des conditions générales d'utilisation",
"Inactivité prolongée",
"Comportement inapproprié / Signalements",
"Demande directe de l'utilisateur",
"Violation des règles de publication",
"Autre"
];
const delays = [
{ label: "Immédiat", value: 0 },
{ label: "7 Jours", value: 7 },
{ label: "15 Jours", value: 15 },
{ label: "30 Jours", value: 30 }
];
const handleDelete = async () => {
const finalReason = reason === "Autre" ? customReason : reason;
if (reason === "Autre" && !customReason.trim()) {
toast.error("Veuillez préciser la raison");
return;
}
setIsDeleting(true);
try {
const result = await scheduleUserDeletion(userId, finalReason, delay);
if (result.success) {
toast.success(`La suppression du compte de ${userName} a été programmée.`);
setIsOpen(false);
} else {
toast.error(result.error || "Échec de la programmation de la suppression");
}
} catch (error) {
toast.error("Une erreur est survenue");
} finally {
setIsDeleting(false);
}
};
const modalContent = (
<div className="fixed inset-0 z-[9999] 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-800 rounded-2xl w-full max-w-lg p-6 shadow-2xl animate-in zoom-in-95 duration-200">
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 bg-red-500/10 rounded-xl flex items-center justify-center text-red-500">
<UserX className="w-6 h-6" />
</div>
<div>
<h3 className="text-xl font-bold text-white">Supprimer le compte</h3>
<p className="text-sm text-slate-400">Configuration de la suppression pour {userName}</p>
</div>
</div>
<div className="space-y-4 mb-6">
{/* Raison selector */}
<div>
<label className="block text-xs font-bold text-slate-400 uppercase mb-2">Raison de la suppression</label>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white focus:ring-2 focus:ring-red-500 transition-all outline-none"
>
{reasons.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
{/* Custom Reason Field */}
{reason === "Autre" && (
<div className="animate-in slide-in-from-top-2 duration-200">
<label className="block text-xs font-bold text-slate-400 uppercase mb-2">Précisez la raison</label>
<textarea
required
value={customReason}
onChange={(e) => setCustomReason(e.target.value)}
placeholder="Saisissez le motif ici..."
className="w-full h-24 bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-white focus:ring-2 focus:ring-red-500 transition-all outline-none resize-none"
/>
</div>
)}
{/* Delay selector */}
<div>
<label className="block text-xs font-bold text-slate-400 uppercase mb-2">Délai avant suppression définitive</label>
<div className="grid grid-cols-4 gap-2">
{delays.map((d) => (
<button
key={d.value}
onClick={() => setDelay(d.value)}
className={`py-2 rounded-lg text-xs font-bold border transition-all ${
delay === d.value
? 'bg-red-500 border-red-500 text-white'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-600'
}`}
>
{d.label}
</button>
))}
</div>
</div>
</div>
<div className="bg-slate-950/50 border border-slate-800 rounded-xl p-4 mb-6">
<ul className="space-y-2 text-xs text-slate-400">
<li className="flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-red-500 mt-1.5 shrink-0"></span>
Le compte sera désactivé immédiatement.
</li>
{delay > 0 && (
<li className="flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-red-500 mt-1.5 shrink-0"></span>
L'utilisateur aura {delay} jours pour réactiver son compte.
</li>
)}
<li className="flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-red-500 mt-1.5 shrink-0"></span>
Suppression définitive : {delay === 0 ? "Immédiate" : `Le ${new Date(Date.now() + delay * 86400000).toLocaleDateString()}`}
</li>
</ul>
</div>
<div className="flex gap-3">
<button
onClick={() => setIsOpen(false)}
disabled={isDeleting}
className="flex-1 px-4 py-2.5 bg-slate-800 text-white rounded-xl font-bold hover:bg-slate-700 transition-colors disabled:opacity-50"
>
Annuler
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex-1 px-4 py-2.5 bg-red-600 text-white rounded-xl font-bold hover:bg-red-500 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Confirmer"
)}
</button>
</div>
</div>
</div>
);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-red-500/20"
title="Supprimer le compte"
>
<UserX className="w-3 h-3" />
Supprimer
</button>
{mounted && isOpen && createPortal(modalContent, document.body)}
</>
);
}

View File

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

@@ -6,6 +6,7 @@ import { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } from '
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal'; import SuspensionModal from './SuspensionModal';
import BusinessSeoModal from './BusinessSeoModal'; import BusinessSeoModal from './BusinessSeoModal';
import DeleteAccountButton from './DeleteAccountButton';
interface EntrepreneurActionsProps { interface EntrepreneurActionsProps {
businessId: string; businessId: string;
@@ -98,6 +99,10 @@ export default function EntrepreneurActions({
<span className="text-[10px] uppercase font-bold">Shop</span> <span className="text-[10px] uppercase font-bold">Shop</span>
</button> </button>
)} )}
<div className="h-8 w-[1px] bg-slate-800 mx-1 self-center" />
<DeleteAccountButton userId={userId} userName={userName} />
</div> </div>
<SuspensionModal <SuspensionModal

View File

@@ -3,7 +3,7 @@
import { useTransition, useState, useEffect } from 'react'; import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createEvent, updateEvent } from '@/app/actions/event'; import { createEvent, updateEvent } from '@/app/actions/event';
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } from 'lucide-react'; import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send, FileJson } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor'; import RichTextEditor from './RichTextEditor';
@@ -19,8 +19,18 @@ interface Props {
export default function EventForm({ initialData }: Props) { export default function EventForm({ initialData }: Props) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
// JSON prefill state
const [currentData, setCurrentData] = useState<any>(initialData || {});
const [jsonString, setJsonString] = useState('');
const [showJsonInput, setShowJsonInput] = useState(false);
const [jsonError, setJsonError] = useState<string | null>(null);
// Form input states
const [description, setDescription] = useState(initialData?.description || ''); const [description, setDescription] = useState(initialData?.description || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || ''); const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
const [tags, setTags] = useState<string[]>(initialData?.tags || []); const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]); const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState( const [publishedAtValue, setPublishedAtValue] = useState(
@@ -35,6 +45,46 @@ export default function EventForm({ initialData }: Props) {
getAllTags().then(setAllExistingTags); getAllTags().then(setAllExistingTags);
}, []); }, []);
const handleApplyJson = () => {
try {
if (!jsonString.trim()) {
throw new Error("Veuillez coller un code JSON.");
}
const parsed = JSON.parse(jsonString);
// Extract new combined tags
const newTags = Array.isArray(parsed.tags) ? [...parsed.tags] : [...tags];
if (parsed.type && !newTags.includes(parsed.type)) {
newTags.push(parsed.type);
}
// Update states to prefill rich components
if (parsed.description) setDescription(parsed.description);
if (parsed.thumbnailUrl) setThumbnailUrl(parsed.thumbnailUrl);
setTags(newTags);
// Update currentData to reset defaultValues on native inputs via form key remount
setCurrentData({
...currentData,
title: parsed.title || currentData.title || '',
location: parsed.location || currentData.location || '',
date: parsed.date || currentData.date || '',
link: parsed.link || parsed.registrationUrl || currentData.link || '',
slug: parsed.slug || currentData.slug || '',
metaTitle: parsed.metaTitle || currentData.metaTitle || '',
metaDescription: parsed.metaDescription || currentData.metaDescription || '',
_prefillKey: Date.now() // Force form remount
});
setJsonError(null);
setShowJsonInput(false);
setJsonString('');
toast.success("Champs de l'événement pré-remplis !");
} catch (err: any) {
setJsonError("Format JSON invalide. Vérifiez la syntaxe : " + (err.message || ''));
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
@@ -53,6 +103,8 @@ export default function EventForm({ initialData }: Props) {
metaDescription: formData.get('metaDescription') as string, metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined, publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus, status: formData.get('status') as ContentStatus,
coverPosition: coverPosition,
coverZoom: coverZoom,
}; };
if (!data.thumbnailUrl) { if (!data.thumbnailUrl) {
@@ -67,7 +119,7 @@ export default function EventForm({ initialData }: Props) {
if (result.success) { if (result.success) {
toast.success(initialData ? "Événement mis à jour" : "Événement créé avec succès"); toast.success(initialData ? "Événement mis à jour" : "Événement créé avec succès");
router.push('/blog'); router.push('/actualites');
router.refresh(); router.refresh();
} else { } else {
toast.error(result.error || "Une erreur est survenue"); toast.error(result.error || "Une erreur est survenue");
@@ -75,11 +127,22 @@ export default function EventForm({ initialData }: Props) {
}); });
}; };
const getDefaultDateStr = () => {
if (currentData.date) {
try {
return new Date(currentData.date).toISOString().slice(0, 16);
} catch(e) {
return '';
}
}
return '';
};
return ( return (
<div className="max-w-4xl mx-auto pb-20"> <div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4"> <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" /> <ArrowLeft className="w-5 h-5" />
</Link> </Link>
<h1 className="text-3xl font-bold text-white"> <h1 className="text-3xl font-bold text-white">
@@ -88,7 +151,63 @@ export default function EventForm({ initialData }: Props) {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-8"> {/* PREFILL JSON SECTION */}
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 mb-8 shadow-md">
<button
type="button"
onClick={() => setShowJsonInput(!showJsonInput)}
className="flex items-center justify-between w-full text-left font-bold text-sm text-brand-400 hover:text-brand-300 transition-colors"
>
<span className="flex items-center gap-2">
<FileJson className="w-5 h-5 text-amber-400" />
Importer JSON (Pré-remplir les champs de l'événement automatiquement)
</span>
<span className="text-xs bg-slate-800 px-2.5 py-1 rounded text-slate-400 border border-slate-700">
{showJsonInput ? 'Masquer' : 'Déplier'}
</span>
</button>
{showJsonInput && (
<div className="mt-4 pt-4 border-t border-slate-800 space-y-3 animate-in fade-in duration-200">
<p className="text-xs text-slate-400">
Collez le code JSON généré par Gemini pour remplir instantanément le titre, la date, le lieu, l'affiche, l'URL d'inscription, la description, le slug et les attributs SEO.
</p>
{jsonError && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 p-2.5 rounded-lg font-medium">
{jsonError}
</div>
)}
<textarea
value={jsonString}
onChange={(e) => setJsonString(e.target.value)}
placeholder={'{\n "title": "Afro Tech Summit 2026",\n "description": "...",\n "date": "2026-09-15T09:00:00Z",\n "location": "...",\n "thumbnailUrl": "...",\n "slug": "afro-tech-summit-2026",\n "tags": ["Conférence", "Tech", "Rwanda"],\n "metaTitle": "Afro Tech Summit 2026 - Kigali",\n "metaDescription": "Participez au sommet d\'innovation..."\n}'}
rows={8}
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-xs font-mono text-emerald-400 focus:outline-none focus:border-amber-500"
spellCheck={false}
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setJsonString('{\n "title": "Afro Tech Summit 2026",\n "description": "<p>Le plus grand rassemblement des innovateurs de la tech africaine avec plus de 500 startups attendues.</p>",\n "date": "2026-09-15T09:00:00Z",\n "location": "Kigali Convention Centre, Rwanda",\n "thumbnailUrl": "https://images.unsplash.com/photo-1540575467063-178a50c2df87",\n "link": "https://afrotechsummit2026.com",\n "slug": "afro-tech-summit-2026",\n "tags": ["Conférence", "Tech", "Rwanda"],\n "metaTitle": "Afro Tech Summit 2026 - Kigali, Rwanda",\n "metaDescription": "Rejoignez l\'élite de la tech africaine au Kigali Convention Centre pour 3 jours d\'échanges et d\'opportunités d\'investissement."\n}');
}}
className="px-3 py-1.5 text-xs text-slate-400 hover:text-white transition-colors"
>
Exemple complet
</button>
<button
type="button"
onClick={handleApplyJson}
className="px-4 py-1.5 text-xs font-bold bg-amber-600 hover:bg-amber-700 text-white rounded-lg transition-colors shadow-sm"
>
Appliquer les données
</button>
</div>
</div>
)}
</div>
<form key={currentData._prefillKey || 'form'} onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6"> <div className="card space-y-6">
<div className="flex items-center justify-between border-b border-slate-800 pb-4"> <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> <h2 className="text-xl font-semibold text-white">Informations Générales</h2>
@@ -96,7 +215,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select <select
name="status" name="status"
defaultValue={initialData?.status || 'PUBLISHED'} defaultValue={currentData.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" 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="DRAFT">Brouillon</option>
@@ -111,7 +230,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Titre de l'événement</label> <label className="text-sm font-medium text-slate-400">Titre de l'événement</label>
<input <input
name="title" name="title"
defaultValue={initialData?.title} defaultValue={currentData.title}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: Conférence AfroHub 2026" placeholder="Ex: Conférence AfroHub 2026"
@@ -124,7 +243,7 @@ export default function EventForm({ initialData }: Props) {
<input <input
name="date" name="date"
type="datetime-local" type="datetime-local"
defaultValue={initialData?.date ? new Date(initialData.date).toISOString().slice(0, 16) : ''} defaultValue={getDefaultDateStr()}
required required
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" 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"
/> />
@@ -139,7 +258,7 @@ export default function EventForm({ initialData }: Props) {
<MapPin className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" /> <MapPin className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input <input
name="location" name="location"
defaultValue={initialData?.location} defaultValue={currentData.location}
required required
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" 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"
placeholder="Ex: Abidjan, Côte-d'Ivoire / Zoom" placeholder="Ex: Abidjan, Côte-d'Ivoire / Zoom"
@@ -170,6 +289,11 @@ export default function EventForm({ initialData }: Props) {
value={thumbnailUrl} value={thumbnailUrl}
onChange={setThumbnailUrl} onChange={setThumbnailUrl}
name="thumbnailUrl" name="thumbnailUrl"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -178,7 +302,7 @@ export default function EventForm({ initialData }: Props) {
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" /> <LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input <input
name="link" name="link"
defaultValue={initialData?.link} defaultValue={currentData.link}
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" 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"
placeholder="https://..." placeholder="https://..."
/> />
@@ -205,7 +329,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label> <label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
<input <input
name="slug" name="slug"
defaultValue={initialData?.slug || ''} defaultValue={currentData.slug || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: conference-afrohub-2026" placeholder="ex: conference-afrohub-2026"
/> />
@@ -226,7 +350,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label> <label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input <input
name="metaTitle" name="metaTitle"
defaultValue={initialData?.metaTitle || ''} defaultValue={currentData.metaTitle || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Titre optimisé pour Google" placeholder="Titre optimisé pour Google"
/> />
@@ -236,7 +360,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label> <label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea <textarea
name="metaDescription" name="metaDescription"
defaultValue={initialData?.metaDescription || ''} defaultValue={currentData.metaDescription || ''}
rows={3} rows={3}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Description courte pour les résultats de recherche..." placeholder="Description courte pour les résultats de recherche..."

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useTransition } from 'react'; import { useState, useEffect, useTransition } from 'react';
import { createPortal } from 'react-dom';
import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business'; import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business';
import { Star, X, Loader2, Save } from 'lucide-react'; import { Star, X, Loader2, Save } from 'lucide-react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@@ -19,6 +20,12 @@ interface Props {
export default function FeaturedModal({ business }: Props) { export default function FeaturedModal({ business }: Props) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => { const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -54,94 +61,97 @@ export default function FeaturedModal({ business }: Props) {
} }
}; };
const modalContent = (
<div className="modal-overlay" style={{ zIndex: 9999 }}>
<div className="modal-content max-w-md shadow-2xl">
<button
onClick={() => setIsOpen(false)}
className="absolute top-4 right-4 text-slate-500 hover:text-white"
>
<X className="w-6 h-6" />
</button>
<div className="mb-6">
<h2 className="text-2xl font-bold text-white mb-2">💰 Mise en avant / Afroshine</h2>
<p className="text-slate-400 text-sm">
Activez la présence dans la section <strong>"Entreprises à la une"</strong> sur l'accueil. Vous pouvez aussi renseigner les détails optionnels pour l'afficher en <strong>Tête d'affiche (Afroshine)</strong> dans l'annuaire.
</p>
</div>
<form onSubmit={handleToggleFeature} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du fondateur (Optionnel)</label>
<input
name="founderName"
defaultValue={business.founderName || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="Ex: Aliko Dangote"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur (Optionnel)</label>
<input
name="founderImageUrl"
defaultValue={business.founderImageUrl || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Métrique Clé (Optionnel, ex: Impact)</label>
<input
name="keyMetric"
defaultValue={business.keyMetric || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="Ex: +500 emplois créés"
/>
</div>
<div className="pt-4 flex flex-col gap-3">
<button
type="submit"
disabled={isPending}
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 Afroshine
</button>
{business.isFeatured && (
<button
type="button"
onClick={handleRemove}
disabled={isPending}
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
>
Révoquer le titre
</button>
)}
</div>
</form>
</div>
</div>
);
return ( return (
<> <>
<button <button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-all flex items-center gap-2 mx-auto ${ className={`px-2.5 py-1 rounded-md border text-xs font-medium transition-all flex items-center gap-1.5 mx-auto whitespace-nowrap w-fit ${
business.isFeatured business.isFeatured
? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20' ? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20'
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white' : 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
}`} }`}
title="Configurer la mise en avant Afroshine (Annuaire)"
> >
<Star className={`w-4 h-4 ${business.isFeatured ? 'fill-amber-500' : ''}`} /> <Star className={`w-3.5 h-3.5 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
{business.isFeatured ? 'Vedette Active' : 'Mettre en avant'} {business.isFeatured ? 'Actif' : 'Activer'}
</button> </button>
{isOpen && ( {mounted && isOpen && createPortal(modalContent, document.body)}
<div className="modal-overlay">
<div className="modal-content">
<button
onClick={() => setIsOpen(false)}
className="absolute top-4 right-4 text-slate-500 hover:text-white"
>
<X className="w-6 h-6" />
</button>
<div className="mb-6">
<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>
<form onSubmit={handleToggleFeature} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du fondateur</label>
<input
name="founderName"
defaultValue={business.founderName || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="Ex: Aliko Dangote"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur</label>
<input
name="founderImageUrl"
defaultValue={business.founderImageUrl || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Métrique Clé (ex: Chiffre d'affaires, Impact)</label>
<input
name="keyMetric"
defaultValue={business.keyMetric || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="Ex: +500 emplois créés"
/>
</div>
<div className="pt-4 flex flex-col gap-3">
<button
type="submit"
disabled={isPending}
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 Afroshine
</button>
{business.isFeatured && (
<button
type="button"
onClick={handleRemove}
disabled={isPending}
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
>
Révoquer le titre
</button>
)}
</div>
</form>
</div>
</div>
)}
</> </>
); );
} }

View File

@@ -11,9 +11,25 @@ interface Props {
label?: string; label?: string;
placeholder?: string; placeholder?: string;
name?: string; name?: string;
showPositionControls?: boolean;
position?: string;
zoom?: number;
onPositionChange?: (value: string) => void;
onZoomChange?: (value: number) => void;
} }
export default function ImageUploader({ value, onChange, label, placeholder, name }: Props) { export default function ImageUploader({
value,
onChange,
label,
placeholder,
name,
showPositionControls = false,
position = "50% 50%",
zoom = 1,
onPositionChange,
onZoomChange
}: Props) {
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url'); const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -23,8 +39,9 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
if (!file) return; if (!file) return;
// Basic validation // Basic validation
if (!file.type.startsWith('image/')) { const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/pjpeg'];
toast.error("Le fichier doit être une image"); if (!allowedTypes.includes(file.type) && !file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image (JPG, PNG ou WEBP)");
return; return;
} }
@@ -92,7 +109,7 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
<input <input
type="text" type="text"
name={name} name={name}
value={value} value={value || ""}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "https://images.unsplash.com/..."} 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" 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"
@@ -120,37 +137,111 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
</div> </div>
</div> </div>
) : ( ) : (
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700"> <div className="space-y-4">
<img <div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
src={value} <img
alt="Prévisualisation" src={value}
className="w-full h-full object-cover" 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"> style={{
<button objectPosition: position,
type="button" transform: `scale(${zoom})`,
onClick={() => fileInputRef.current?.click()} transformOrigin: position
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" /> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
</button> <button
<button type="button"
type="button" onClick={() => fileInputRef.current?.click()}
onClick={clearImage} className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors" >
> <Upload className="w-5 h-5" />
<X className="w-5 h-5" /> </button>
</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 inputs for values to be sent via form if needed */}
<input type="hidden" name={name} value={value || ""} />
{showPositionControls && (
<>
<input type="hidden" name={`${name}Position`} value={position} />
<input type="hidden" name={`${name}Zoom`} value={zoom.toString()} />
</>
)}
</div> </div>
{/* Hidden input for value to be sent via form if needed */}
<input type="hidden" name={name} value={value} /> {showPositionControls && (
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-800 space-y-4 animate-in fade-in duration-300">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 space-y-1">
<div className="flex justify-between">
<label className="text-[10px] font-bold text-slate-500 uppercase">Zoom : {zoom.toFixed(1)}x</label>
<button
type="button"
onClick={() => onZoomChange?.(1)}
className="text-[10px] text-indigo-400 hover:text-white"
>
Reset
</button>
</div>
<input
type="range"
min="1"
max="3"
step="0.1"
value={zoom}
onChange={(e) => onZoomChange?.(parseFloat(e.target.value))}
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] font-bold text-slate-500 uppercase">Position X : {position.split(' ')[0]}</label>
<input
type="range"
min="0"
max="100"
step="1"
value={parseInt(position.split(' ')[0])}
onChange={(e) => {
const y = position.split(' ')[1];
onPositionChange?.(`${e.target.value}% ${y}`);
}}
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold text-slate-500 uppercase">Position Y : {position.split(' ')[1]}</label>
<input
type="range"
min="0"
max="100"
step="1"
value={parseInt(position.split(' ')[1])}
onChange={(e) => {
const x = position.split(' ')[0];
onPositionChange?.(`${x} ${e.target.value}%`);
}}
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
/>
</div>
</div>
</div>
)}
</div> </div>
)} )}
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
accept="image/*" accept="image/png, image/jpeg, image/jpg, image/webp"
className="hidden" className="hidden"
/> />
</div> </div>

View File

@@ -0,0 +1,393 @@
"use client";
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost } from '@/app/actions/actualites';
import { createEvent } from '@/app/actions/event';
import { createInterview } from '@/app/actions/interview';
import { FileJson, AlertCircle, CheckCircle2, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import toast from 'react-hot-toast';
const articleExample = `{
"title": "Les 5 tendances de la Tech Africaine en 2026",
"excerpt": "Découvrez comment l'intelligence artificielle et la FinTech redéfinissent l'écosystème entrepreneurial sur le continent cette année.",
"content": "<p>L'année 2026 marque un tournant décisif. Les startups...</p><h3>L'IA au service de l'agriculture</h3><p>De nouvelles solutions émergent...</p>",
"author": "Rédaction Afrohub",
"imageUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&q=80&w=1200",
"type": "Technologie"
}`;
const eventExample = `{
"title": "Afro Tech Summit 2026",
"description": "<p>Le plus grand rassemblement des innovateurs de la tech africaine.</p>",
"date": "2026-09-15T09:00:00Z",
"location": "Kigali Convention Centre, Rwanda",
"thumbnailUrl": "https://images.unsplash.com/photo-1540575467063-178a50c2df87",
"type": "Conférence",
"link": "https://afrotechsummit2026.com"
}`;
const interviewExample = `{
"title": "Comment nous avons levé 2M€ pour révolutionner le transport",
"guestName": "Aminata Diallo",
"companyName": "GoAfrica Logistique",
"role": "CEO & Fondatrice",
"type": "TEXT",
"excerpt": "Entretien exclusif avec Aminata Diallo sur les défis de la chaîne d'approvisionnement en Afrique de l'Ouest.",
"content": "<p><strong>Afrohub : Qu'est-ce qui vous a poussée à lancer GoAfrica ?</strong></p><p>Aminata : Le constat était simple...</p>",
"thumbnailUrl": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=1200",
"tags": ["Logistique", "Interview", "Sénégal"]
}`;
export default function ImportJsonForm() {
const router = useRouter();
const [jsonInput, setJsonInput] = useState('');
const [importType, setImportType] = useState<'article' | 'event' | 'interview'>('article');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleFormatJson = () => {
try {
const parsed = JSON.parse(jsonInput);
setJsonInput(JSON.stringify(parsed, null, 2));
setError(null);
} catch (e) {
setError("Le JSON actuel est mal formaté. Impossible de le formater.");
}
};
const handleImport = async () => {
setError(null);
setSuccess(null);
setLoading(true);
try {
if (!jsonInput.trim()) {
throw new Error("Le champ JSON est vide.");
}
let parsedData;
try {
parsedData = JSON.parse(jsonInput);
} catch (e) {
throw new Error("Format JSON invalide. Veuillez vérifier la syntaxe (guillemets, virgules, etc).");
}
if (importType === 'article') {
const required = ['title', 'excerpt', 'content', 'author', 'imageUrl'];
const missing = required.filter(field => !parsedData[field]);
if (missing.length > 0) {
throw new Error("Il manque les champs obligatoires suivants : " + missing.join(', '));
}
const tags = parsedData.tags || [];
if (parsedData.type && !tags.includes(parsedData.type)) {
tags.push(parsedData.type);
}
const result = await createBlogPost({
title: parsedData.title,
excerpt: parsedData.excerpt,
content: parsedData.content,
author: parsedData.author,
imageUrl: parsedData.imageUrl,
tags: tags,
publishedAt: parsedData.publishedAt ? new Date(parsedData.publishedAt) : new Date(Date.now() - 60000),
});
if (result.success) {
toast.success("Article importé avec succès !");
setSuccess("L'article a été importé et publié avec succès.");
setJsonInput('');
} else {
throw new Error(result.error || "Erreur lors de la création de l'article.");
}
} else if (importType === 'event') {
const required = ['title', 'description', 'date', 'location', 'thumbnailUrl'];
const missing = required.filter(field => !parsedData[field]);
if (missing.length > 0) {
throw new Error("Il manque les champs obligatoires suivants : " + missing.join(', '));
}
const tags = parsedData.tags || [];
if (parsedData.type && !tags.includes(parsedData.type)) {
tags.push(parsedData.type);
}
const result = await createEvent({
title: parsedData.title,
description: parsedData.description,
date: new Date(parsedData.date),
location: parsedData.location,
thumbnailUrl: parsedData.thumbnailUrl,
link: parsedData.link || parsedData.registrationUrl,
tags: tags,
publishedAt: parsedData.publishedAt ? new Date(parsedData.publishedAt) : new Date(Date.now() - 60000),
});
if (result.success) {
toast.success("Événement importé avec succès !");
setSuccess("L'événement a été importé et publié avec succès.");
setJsonInput('');
} else {
throw new Error(result.error || "Erreur lors de la création de l'événement.");
}
} else {
// Validation Interview
const required = ['title', 'guestName', 'companyName', 'role', 'type', 'excerpt', 'thumbnailUrl'];
const missing = required.filter(field => !parsedData[field]);
if (missing.length > 0) {
throw new Error("Il manque les champs obligatoires suivants : " + missing.join(', '));
}
const tags = parsedData.tags || [];
if (parsedData.typeTag && !tags.includes(parsedData.typeTag)) {
tags.push(parsedData.typeTag);
}
const result = await createInterview({
title: parsedData.title,
guestName: parsedData.guestName,
companyName: parsedData.companyName,
role: parsedData.role,
type: parsedData.type as any, // 'TEXT' | 'VIDEO'
excerpt: parsedData.excerpt,
content: parsedData.content || '',
thumbnailUrl: parsedData.thumbnailUrl,
videoUrl: parsedData.videoUrl,
duration: parsedData.duration,
tags: tags,
publishedAt: parsedData.publishedAt ? new Date(parsedData.publishedAt) : new Date(Date.now() - 60000),
});
if (result.success) {
toast.success("Interview importée avec succès !");
setSuccess("L'interview a été importée et publiée avec succès.");
setJsonInput('');
} else {
throw new Error(result.error || "Erreur lors de la création de l'interview.");
}
}
router.refresh();
} catch (err: any) {
setError(err.message || "Une erreur est survenue lors de l'importation.");
} finally {
setLoading(false);
}
};
const loadExample = () => {
if (importType === 'article') setJsonInput(articleExample);
else if (importType === 'event') setJsonInput(eventExample);
else setJsonInput(interviewExample);
setError(null);
setSuccess(null);
};
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<Link href="/actualites" className="text-slate-400 hover:text-white flex items-center gap-2 text-sm font-medium w-fit">
<ArrowLeft className="w-4 h-4" />
Retour au CMS
</Link>
</div>
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
<div className="p-6 border-b border-slate-800 bg-slate-800/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-500/20 text-indigo-400 rounded-lg">
<FileJson className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold text-white">Importateur JSON</h1>
<p className="text-sm text-slate-400">Importez directement vos contenus depuis un format JSON valide.</p>
</div>
</div>
<div className="flex bg-slate-900 p-1 rounded-lg border border-slate-700 flex-wrap gap-1">
<button
onClick={() => { setImportType('article'); setJsonInput(''); setError(null); setSuccess(null); }}
className={"px-3 py-1.5 rounded-md text-xs font-bold transition-all " + (importType === 'article' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white')}
>
Article
</button>
<button
onClick={() => { setImportType('interview'); setJsonInput(''); setError(null); setSuccess(null); }}
className={"px-3 py-1.5 rounded-md text-xs font-bold transition-all " + (importType === 'interview' ? 'bg-emerald-600 text-white shadow-sm' : 'text-slate-400 hover:text-white')}
>
Interview
</button>
<button
onClick={() => { setImportType('event'); setJsonInput(''); setError(null); setSuccess(null); }}
className={"px-3 py-1.5 rounded-md text-xs font-bold transition-all " + (importType === 'event' ? 'bg-amber-600 text-white shadow-sm' : 'text-slate-400 hover:text-white')}
>
Événement
</button>
</div>
</div>
<div className="p-6">
{error && (
<div className="mb-6 bg-red-500/10 border border-red-500/20 rounded-lg p-4 flex items-start gap-3 text-red-400">
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<h3 className="font-bold">Erreur de formatage ou d'import</h3>
<p className="text-sm mt-1">{error}</p>
</div>
</div>
)}
{success && (
<div className="mb-6 bg-emerald-500/10 border border-emerald-500/20 rounded-lg p-4 flex items-start gap-3 text-emerald-400">
<CheckCircle2 className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<h3 className="font-bold">Succès</h3>
<p className="text-sm mt-1">{success}</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 flex flex-col">
<div className="flex justify-between items-center mb-2">
<label className="block text-sm font-medium text-slate-300">Code JSON</label>
<div className="flex gap-3">
<button onClick={loadExample} className="text-xs text-brand-400 hover:text-brand-300 underline font-medium">
Charger un exemple
</button>
<button onClick={handleFormatJson} className="text-xs text-slate-400 hover:text-white underline font-medium">
Formater le code
</button>
</div>
</div>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
className="w-full h-[400px] bg-slate-950 border border-slate-700 rounded-xl p-4 text-emerald-400 font-mono text-sm focus:border-brand-500 focus:ring-1 focus:ring-brand-500 outline-none resize-none"
placeholder="Collez votre JSON ici..."
spellCheck="false"
/>
<p className="text-xs text-slate-500 mt-2">
Assurez-vous que les guillemets soient des guillemets droits (") et non des guillemets typographiques (« » ou “ ”).
</p>
</div>
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-5 h-fit">
<h3 className="font-bold text-white mb-4">Structure attendue</h3>
{importType === 'article' ? (
<ul className="space-y-3 text-sm">
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">title</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">excerpt</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">content</span>
<span className="text-slate-400">Requis (HTML)</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">author</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">imageUrl</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between pb-2">
<span className="text-slate-400 font-mono">type</span>
<span className="text-slate-500">Optionnel</span>
</li>
</ul>
) : importType === 'interview' ? (
<ul className="space-y-3 text-sm">
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">title</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">guestName</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">companyName</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">role</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">type</span>
<span className="text-slate-400">TEXT / VIDEO</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">thumbnailUrl</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between pb-2">
<span className="text-slate-400 font-mono">videoUrl</span>
<span className="text-slate-500">Optionnel</span>
</li>
</ul>
) : (
<ul className="space-y-3 text-sm">
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">title</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">description</span>
<span className="text-slate-400">Requis (HTML)</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">date</span>
<span className="text-slate-400">Requis (ISO)</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">location</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">thumbnailUrl</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between pb-2">
<span className="text-slate-400 font-mono">link</span>
<span className="text-slate-500">Optionnel</span>
</li>
</ul>
)}
</div>
</div>
</div>
<div className="p-6 border-t border-slate-800 bg-slate-900 flex justify-end">
<button
onClick={handleImport}
disabled={loading}
className="bg-brand-600 hover:bg-brand-700 text-white px-8 py-2.5 rounded-lg font-bold transition-colors disabled:opacity-50 flex items-center gap-2"
>
{loading ? (
<>Importation en cours...</>
) : (
<>Importer le contenu</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useTransition, useState, useEffect } from 'react'; import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createInterview, updateInterview } from '@/app/actions/interview'; import { createInterview, updateInterview } from '@/app/actions/interview';
import { Loader2, ArrowLeft, Save, Video, FileText, Calendar } from 'lucide-react'; import { Loader2, ArrowLeft, Save, Video, FileText, Calendar, FileJson } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { InterviewType, ContentStatus } from '@prisma/client'; import { InterviewType, ContentStatus } from '@prisma/client';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@@ -19,6 +19,14 @@ interface Props {
export default function InterviewForm({ initialData }: Props) { export default function InterviewForm({ initialData }: Props) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
// JSON prefill state
const [currentData, setCurrentData] = useState<any>(initialData || {});
const [jsonString, setJsonString] = useState('');
const [showJsonInput, setShowJsonInput] = useState(false);
const [jsonError, setJsonError] = useState<string | null>(null);
// Form input states
const [content, setContent] = useState(initialData?.content || ''); const [content, setContent] = useState(initialData?.content || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || ''); const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []); const [tags, setTags] = useState<string[]>(initialData?.tags || []);
@@ -35,6 +43,50 @@ export default function InterviewForm({ initialData }: Props) {
getAllTags().then(setAllExistingTags); getAllTags().then(setAllExistingTags);
}, []); }, []);
const handleApplyJson = () => {
try {
if (!jsonString.trim()) {
throw new Error("Veuillez coller un code JSON.");
}
const parsed = JSON.parse(jsonString);
// Extract new combined tags
const newTags = Array.isArray(parsed.tags) ? [...parsed.tags] : [...tags];
if (parsed.typeTag && !newTags.includes(parsed.typeTag)) {
newTags.push(parsed.typeTag);
}
// Update states to prefill rich components
if (parsed.content) setContent(parsed.content);
if (parsed.thumbnailUrl) setThumbnailUrl(parsed.thumbnailUrl);
setTags(newTags);
// Update currentData to reset defaultValues on native inputs via form key remount
setCurrentData({
...currentData,
title: parsed.title || currentData.title || '',
guestName: parsed.guestName || currentData.guestName || '',
companyName: parsed.companyName || currentData.companyName || '',
role: parsed.role || currentData.role || '',
type: parsed.type || currentData.type || 'TEXT',
videoUrl: parsed.videoUrl || currentData.videoUrl || '',
duration: parsed.duration || currentData.duration || '',
excerpt: parsed.excerpt || currentData.excerpt || '',
slug: parsed.slug || currentData.slug || '',
metaTitle: parsed.metaTitle || currentData.metaTitle || '',
metaDescription: parsed.metaDescription || currentData.metaDescription || '',
_prefillKey: Date.now() // Force form remount
});
setJsonError(null);
setShowJsonInput(false);
setJsonString('');
toast.success("Champs d'interview pré-remplis !");
} catch (err: any) {
setJsonError("Format JSON invalide. Vérifiez la syntaxe : " + (err.message || ''));
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
@@ -71,7 +123,7 @@ export default function InterviewForm({ initialData }: Props) {
if (result.success) { if (result.success) {
toast.success(initialData ? "Interview mise à jour" : "Interview créée avec succès"); toast.success(initialData ? "Interview mise à jour" : "Interview créée avec succès");
router.push('/blog'); router.push('/actualites');
router.refresh(); router.refresh();
} else { } else {
toast.error(result.error || "Une erreur est survenue"); toast.error(result.error || "Une erreur est survenue");
@@ -83,7 +135,7 @@ export default function InterviewForm({ initialData }: Props) {
<div className="max-w-4xl mx-auto pb-20"> <div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4"> <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" /> <ArrowLeft className="w-5 h-5" />
</Link> </Link>
<h1 className="text-3xl font-bold text-white"> <h1 className="text-3xl font-bold text-white">
@@ -92,7 +144,63 @@ export default function InterviewForm({ initialData }: Props) {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-8"> {/* PREFILL JSON SECTION */}
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 mb-8 shadow-md">
<button
type="button"
onClick={() => setShowJsonInput(!showJsonInput)}
className="flex items-center justify-between w-full text-left font-bold text-sm text-brand-400 hover:text-brand-300 transition-colors"
>
<span className="flex items-center gap-2">
<FileJson className="w-5 h-5 text-emerald-400" />
Importer JSON (Pré-remplir les champs d'interview automatiquement)
</span>
<span className="text-xs bg-slate-800 px-2.5 py-1 rounded text-slate-400 border border-slate-700">
{showJsonInput ? 'Masquer' : 'Déplier'}
</span>
</button>
{showJsonInput && (
<div className="mt-4 pt-4 border-t border-slate-800 space-y-3 animate-in fade-in duration-200">
<p className="text-xs text-slate-400">
Collez le code JSON généré par Gemini pour remplir instantanément les infos de l'invité, l'extrait, la miniature, la transcription, le slug et les balises SEO.
</p>
{jsonError && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 p-2.5 rounded-lg font-medium">
{jsonError}
</div>
)}
<textarea
value={jsonString}
onChange={(e) => setJsonString(e.target.value)}
placeholder={'{\n "title": "Comment nous avons levé 2M€...",\n "guestName": "Aminata Diallo",\n "companyName": "GoAfrica Logistique",\n "role": "CEO & Fondatrice",\n "excerpt": "Entretien exclusif...",\n "thumbnailUrl": "...",\n "slug": "interview-aminata-diallo-goafrica",\n "tags": ["Logistique", "Interview", "Sénégal"],\n "metaTitle": "Interview Aminata Diallo - GoAfrica Logistique",\n "metaDescription": "Découvrez le parcours inspirant d\'Aminata Diallo..."\n}'}
rows={8}
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-xs font-mono text-emerald-400 focus:outline-none focus:border-emerald-500"
spellCheck={false}
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setJsonString('{\n "title": "Comment nous avons levé 2M€ pour révolutionner le transport",\n "guestName": "Aminata Diallo",\n "companyName": "GoAfrica Logistique",\n "role": "CEO & Fondatrice",\n "type": "TEXT",\n "excerpt": "Entretien exclusif avec Aminata Diallo sur les défis de la chaîne d\'approvisionnement en Afrique de l\'Ouest.",\n "content": "<p><strong>Afrohub : Qu\'est-ce qui vous a poussée à lancer GoAfrica ?</strong></p><p>Aminata : Le constat était simple...</p>",\n "thumbnailUrl": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=1200",\n "slug": "interview-aminata-diallo-goafrica",\n "tags": ["Logistique", "Interview", "Sénégal"],\n "metaTitle": "Interview Aminata Diallo - GoAfrica Logistique",\n "metaDescription": "Découvrez les secrets de la levée de fonds de 2M€ par GoAfrica Logistique dans cet entretien exclusif avec sa fondatrice."\n}');
}}
className="px-3 py-1.5 text-xs text-slate-400 hover:text-white transition-colors"
>
Exemple complet
</button>
<button
type="button"
onClick={handleApplyJson}
className="px-4 py-1.5 text-xs font-bold bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors shadow-sm"
>
Appliquer les données
</button>
</div>
</div>
)}
</div>
<form key={currentData._prefillKey || 'form'} onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6"> <div className="card space-y-6">
<div className="flex items-center justify-between border-b border-slate-800 pb-4"> <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> <h2 className="text-xl font-semibold text-white">Informations Générales</h2>
@@ -100,7 +208,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select <select
name="status" name="status"
defaultValue={initialData?.status || 'PUBLISHED'} defaultValue={currentData.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" 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="DRAFT">Brouillon</option>
@@ -115,7 +223,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Titre de l'interview</label> <label className="text-sm font-medium text-slate-400">Titre de l'interview</label>
<input <input
name="title" name="title"
defaultValue={initialData?.title} defaultValue={currentData.title}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: Entreprise X : Une success story..." placeholder="Ex: Entreprise X : Une success story..."
@@ -125,7 +233,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Nom de l'invité</label> <label className="text-sm font-medium text-slate-400">Nom de l'invité</label>
<input <input
name="guestName" name="guestName"
defaultValue={initialData?.guestName} defaultValue={currentData.guestName}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: Sarah Koné" placeholder="Ex: Sarah Koné"
@@ -135,7 +243,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Entreprise</label> <label className="text-sm font-medium text-slate-400">Entreprise</label>
<input <input
name="companyName" name="companyName"
defaultValue={initialData?.companyName} defaultValue={currentData.companyName}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: TechAfrica" placeholder="Ex: TechAfrica"
@@ -145,7 +253,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label> <label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
<input <input
name="role" name="role"
defaultValue={initialData?.role} defaultValue={currentData.role}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: Fondatrice & CEO" placeholder="Ex: Fondatrice & CEO"
@@ -158,11 +266,11 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Type d'interview</label> <label className="text-sm font-medium text-slate-400">Type d'interview</label>
<select <select
name="type" name="type"
defaultValue={initialData?.type || 'VIDEO'} defaultValue={currentData.type || 'VIDEO'}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
> >
<option value="VIDEO">Vidéo</option> <option value="VIDEO">Vidéo</option>
<option value="ARTICLE">Article (Texte)</option> <option value="TEXT">Article (Texte)</option>
</select> </select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -195,7 +303,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label> <label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label>
<input <input
name="videoUrl" name="videoUrl"
defaultValue={initialData?.videoUrl} defaultValue={currentData.videoUrl}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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/..." placeholder="https://youtube.com/..."
/> />
@@ -203,7 +311,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label> <label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
<input <input
name="duration" name="duration"
defaultValue={initialData?.duration} defaultValue={currentData.duration}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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" placeholder="Ex: 12 min"
/> />
@@ -215,7 +323,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label> <label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label>
<textarea <textarea
name="excerpt" name="excerpt"
defaultValue={initialData?.excerpt} defaultValue={currentData.excerpt}
required required
rows={2} rows={2}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
@@ -241,7 +349,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label> <label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
<input <input
name="slug" name="slug"
defaultValue={initialData?.slug || ''} defaultValue={currentData.slug || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: interview-sarah-kone-techafrica" placeholder="ex: interview-sarah-kone-techafrica"
/> />
@@ -262,7 +370,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label> <label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input <input
name="metaTitle" name="metaTitle"
defaultValue={initialData?.metaTitle || ''} defaultValue={currentData.metaTitle || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Titre optimisé SEO" placeholder="Titre optimisé SEO"
/> />
@@ -272,7 +380,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label> <label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea <textarea
name="metaDescription" name="metaDescription"
defaultValue={initialData?.metaDescription || ''} defaultValue={currentData.metaDescription || ''}
rows={3} rows={3}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Description courte SEO..." placeholder="Description courte SEO..."

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

@@ -34,12 +34,13 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
const quillRef = useRef<any>(null); const quillRef = useRef<any>(null);
const modules = useMemo(() => ({ const modules = useMemo(() => ({
table: true,
toolbar: { toolbar: {
container: [ container: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }], [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'], ['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }], [{ 'list': 'ordered' }, { 'list': 'bullet' }],
['divider', 'link', 'clean'], ['table', 'divider', 'link', 'clean'],
], ],
handlers: { handlers: {
divider: function() { divider: function() {
@@ -58,7 +59,7 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
'header', 'header',
'bold', 'italic', 'underline', 'strike', 'bold', 'italic', 'underline', 'strike',
'list', 'list',
'divider', 'link', 'divider', 'link', 'table'
]; ];
return ( return (

View File

@@ -16,7 +16,8 @@ import {
Globe, Globe,
FileText, FileText,
Briefcase, Briefcase,
Tags Tags,
Image as ImageIcon
} from 'lucide-react'; } from 'lucide-react';
const menuItems = [ const menuItems = [
@@ -25,10 +26,11 @@ const menuItems = [
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' }, { name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' }, { name: 'Commentaires', icon: MessageSquare, href: '/comments' },
{ name: 'Modération', icon: Flag, href: '/moderation' }, { name: 'Modération', icon: Flag, href: '/moderation' },
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' }, { name: 'Actualités & Interviews', icon: BookOpen, href: '/actualites' },
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' }, { name: 'Forfaits & Services', icon: CreditCard, href: '/plans' },
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' }, { name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
{ name: 'Pays', icon: Globe, href: '/countries' }, { name: 'Pays', icon: Globe, href: '/countries' },
{ name: 'Bannière', icon: ImageIcon, href: '/slides' },
{ name: 'Documents Légaux', icon: FileText, href: '/legal' }, { name: 'Documents Légaux', icon: FileText, href: '/legal' },
{ name: 'Configuration', icon: Settings, href: '/settings' }, { name: 'Configuration', icon: Settings, href: '/settings' },
]; ];
@@ -45,7 +47,7 @@ export default function Sidebar() {
}, [pathname]); // Refresh on navigation }, [pathname]); // Refresh on navigation
return ( return (
<div className="admin-sidebar p-6 flex flex-col"> <div className="admin-sidebar z-50 p-6 flex flex-col">
<div className="flex items-center gap-3 mb-10 px-2"> <div className="flex items-center gap-3 mb-10 px-2">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center"> <div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
<ShieldCheck className="text-white" /> <ShieldCheck className="text-white" />

View File

@@ -0,0 +1,301 @@
"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 [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
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,
coverPosition: coverPosition,
coverZoom: coverZoom,
};
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"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</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,46 @@
"use client";
import { useTransition } from 'react';
import { toggleHomeFeatured } from '@/app/actions/business';
import { Sparkles, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface Props {
id: string;
isHomeFeatured: boolean;
}
export default function ToggleHomeFeatureButton({ id, isHomeFeatured }: Props) {
const [isPending, startTransition] = useTransition();
const handleToggle = () => {
startTransition(async () => {
const result = await toggleHomeFeatured(id, isHomeFeatured);
if (result.success) {
toast.success(isHomeFeatured ? "Retiré de l'accueil" : "Ajouté à l'accueil");
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
return (
<button
onClick={handleToggle}
disabled={isPending}
className={`px-2.5 py-1 rounded-md border text-xs font-medium transition-all flex items-center gap-1.5 mx-auto whitespace-nowrap w-fit ${
isHomeFeatured
? 'bg-indigo-500/10 border-indigo-500/50 text-indigo-400 hover:bg-indigo-500/20'
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
}`}
title="Afficher dans la section 'Entreprises à la une' sur la page d'accueil"
>
{isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Sparkles className={`w-3.5 h-3.5 ${isHomeFeatured ? 'text-indigo-400 fill-indigo-400' : ''}`} />
)}
{isHomeFeatured ? 'Actif' : 'Activer'}
</button>
);
}

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

38
admin/src/lib/mail.ts Normal file
View File

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

View File

@@ -1,20 +1,19 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
// Updated: 2026-04-18 14:28 (Forced reload for PricingPlan model)
import pg from 'pg'
const connectionString = process.env.DATABASE_URL! const connectionString = process.env.DATABASE_URL || "postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?schema=public"
function makePrisma() { const globalForPrisma = globalThis as unknown as { prisma_admin_native?: PrismaClient }
const pool = new pg.Pool({ connectionString })
const adapter = new PrismaPg(pool)
return new PrismaClient({ adapter })
}
const globalForPrisma = globalThis as unknown as { prisma_v3: PrismaClient } export const prisma = globalForPrisma.prisma_admin_native || new PrismaClient({
datasources: {
db: {
url: connectionString,
},
},
})
export const prisma = globalForPrisma.prisma_v3 || makePrisma() if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_admin_native = prisma
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v3 = prisma
export default prisma export default prisma

View File

@@ -8,14 +8,16 @@ import { generateSlug } from '@/lib/utils';
import { Business, BlogPost } from '@/types'; import { Business, BlogPost } from '@/types';
import BusinessCard from '@/components/BusinessCard'; import BusinessCard from '@/components/BusinessCard';
import { useUser } from '@/components/UserProvider'; import { useUser } from '@/components/UserProvider';
import HomeSlider from '@/components/HomeSlider';
interface Props { interface Props {
initialFeatured: Business[]; initialFeatured: Business[];
initialPosts: BlogPost[]; initialPosts: BlogPost[];
initialCategories: any[]; initialCategories: any[];
initialSlides: any[];
} }
const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props) => { const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialSlides }: Props) => {
const router = useRouter(); const router = useRouter();
const { user, settings } = useUser(); const { user, settings } = useUser();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -23,11 +25,11 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured); const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
const [posts, setPosts] = useState<BlogPost[]>(initialPosts); const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
const [categories, setCategories] = useState<any[]>(initialCategories); const [categories, setCategories] = useState<any[]>(initialCategories);
const [slides, setSlides] = useState<any[]>(initialSlides);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingPosts, setLoadingPosts] = useState(false); const [loadingPosts, setLoadingPosts] = useState(false);
const handleSearch = (e: React.FormEvent) => { const handleSearch = (searchTerm: string, locationTerm: string) => {
e.preventDefault();
const params = new URLSearchParams(); const params = new URLSearchParams();
if (searchTerm) params.append('q', searchTerm); if (searchTerm) params.append('q', searchTerm);
if (locationTerm) params.append('location', locationTerm); if (locationTerm) params.append('location', locationTerm);
@@ -36,47 +38,8 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
return ( return (
<div> <div>
{/* Hero Section */} {/* Dynamic Hero Slider */}
<div className="relative bg-dark-900 overflow-hidden"> <HomeSlider slides={slides} onSearch={handleSearch} />
<div className="absolute inset-0 opacity-40">
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" width={1740} height={1160} />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
Boostez votre visibilité dans <br />
<span className="text-brand-500">l'écosystème africain</span>
</h1>
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
</p>
<form onSubmit={handleSearch} className="max-w-3xl bg-white p-2 rounded-lg shadow-xl flex flex-col md:flex-row gap-2">
<div className="flex-1 relative">
<Search className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Que recherchez-vous ? (ex: Développeur, Traiteur...)"
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
<MapPin className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Localisation (ex: Abidjan)"
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
value={locationTerm}
onChange={(e) => setLocationTerm(e.target.value)}
/>
</div>
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
Rechercher
</button>
</form>
</div>
</div>
{/* Featured Categories */} {/* Featured Categories */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
@@ -127,8 +90,8 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2> <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> <p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
</div> </div>
<Link href="/blog" className="text-brand-600 font-medium hover:text-brand-700 flex items-center"> <Link href="/actualites" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout le blog <ArrowRight className="w-4 h-4 ml-1" /> Voir les actualités <ArrowRight className="w-4 h-4 ml-1" />
</Link> </Link>
</div> </div>
@@ -145,7 +108,7 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
}) })
.slice(0, settings?.homeBlogCount || 3) .slice(0, settings?.homeBlogCount || 3)
.map(post => ( .map(post => (
<Link key={post.id} href={`/blog/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all"> <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"> <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" /> <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>

52
app/actions/auth.ts Normal file
View File

@@ -0,0 +1,52 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function reactivateUserAccount(userId: string, reason: string) {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true, deletedAt: true }
});
if (!user) {
return { success: false, error: "Utilisateur non trouvé" };
}
if (!user.deletedAt) {
return { success: false, error: "Ce compte est déjà actif" };
}
await prisma.user.update({
where: { id: userId },
data: {
deletedAt: null,
deletionScheduledAt: null,
reactivationReason: reason
}
});
// Optionnel : Envoyer un mail de confirmation
const settings = await prisma.siteSetting.findUnique({ where: { id: "singleton" } });
if (settings?.reactivateAccountTemplate) {
const { sendEmail } = await import("@/lib/mail");
const html = settings.reactivateAccountTemplate
.replace(/{name}/g, user.name)
.replace(/{siteName}/g, settings.siteName)
.replace(/{reason}/g, reason);
await sendEmail({
to: user.email,
subject: settings.reactivateAccountSubject,
html
});
}
revalidatePath("/");
return { success: true };
} catch (error) {
console.error("Reactivation Error:", error);
return { success: false, error: "Une erreur est survenue lors de la réactivation" };
}
}

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: 'PENDING' as ContentStatus, // 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" };
}
}

42
app/actions/news.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 createNews(data: {
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
tags?: string[];
sources?: any;
}) {
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 || [],
sources: data.sources || [],
status: 'PENDING' as ContentStatus, // Always pending for user submission
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
}
});
revalidatePath("/actualites");
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é" };
}
}

View File

@@ -13,7 +13,13 @@ export async function uploadImage(formData: FormData) {
// Convert to Base64 // Convert to Base64
const base64 = buffer.toString('base64'); const base64 = buffer.toString('base64');
const mimeType = file.type || 'image/jpeg'; let mimeType = file.type || 'image/jpeg';
// Normalize JPEG mime types
if (mimeType === 'image/jpg' || mimeType === 'image/pjpeg') {
mimeType = 'image/jpeg';
}
const dataUrl = `data:${mimeType};base64,${base64}`; const dataUrl = `data:${mimeType};base64,${base64}`;
// Return the data URL directly (it will be saved in the database) // Return the data URL directly (it will be saved in the database)

View File

@@ -0,0 +1,270 @@
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 LightboxImage from '@/components/LightboxImage';
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-white sm:bg-gray-50 min-h-screen py-0 sm:py-12 relative">
{/* Injection du style de force pour le texte */}
<style dangerouslySetInnerHTML={{ __html: noBreakStyle }} />
<div className="max-w-5xl mx-auto px-0 sm:px-6 lg:px-8 flex gap-4 lg:gap-8 justify-center relative">
{/* Conteneur principal de l'article */}
<div className="w-full max-w-3xl relative">
{/* Navigation */}
<div className="mb-4 sm:mb-8 px-4 sm:px-0 pt-4 sm:pt-0">
<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 sm:rounded-xl shadow-none sm:shadow-sm sm:p-10 border-none sm:border sm:border-gray-100 relative z-10">
{/* Image d'en-tête en pleine largeur sur mobile */}
<div className="w-full h-64 md:h-80 relative mb-6 sm:mb-8 sm:rounded-lg overflow-hidden shadow-none sm:shadow-md">
<LightboxImage
src={post.imageUrl}
alt={post.title}
position={post.coverPosition || "50% 50%"}
zoom={post.coverZoom || 1}
/>
</div>
{/* Header de l'article */}
<header className="mb-6 sm:mb-8 border-b border-gray-100 pb-6 sm:pb-8 px-4 sm:px-0">
<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 sm:prose-lg prose-orange max-w-none text-gray-600 px-4 sm:px-0">
{/* 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>
{/* Sources Section */}
{Array.isArray(post.sources) && post.sources.length > 0 && (
<div className="mt-8 sm:mt-12 p-4 sm:p-6 bg-gray-50 sm:rounded-xl border-t sm:border border-gray-100 mx-4 sm:mx-0">
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-400 mb-4 flex items-center gap-2">
<Share2 className="w-4 h-4 text-brand-500" />
Sources & Références
</h3>
<ul className="space-y-3">
{(post.sources as any[]).map((source, index) => (
<li key={index} className="flex items-start gap-3">
<span className="w-1.5 h-1.5 rounded-full bg-brand-500 mt-2 shrink-0" />
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-gray-700 hover:text-brand-600 transition-colors break-all"
>
<span className="font-semibold">{source.title || 'Source'}</span>
<span className="text-gray-400 ml-2 text-sm">{source.url}</span>
</a>
</li>
))}
</ul>
</div>
)}
{/* Partage sur mobile */}
<div className="mt-8 pt-6 border-t border-gray-100 block sm:hidden px-4">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-4 text-center">Partager cet article</p>
<BlogShareButtons title={post.title} layout="horizontal" />
</div>
{/* Partage / Footer */}
<div className="mt-8 sm:mt-12 pt-6 sm:pt-8 border-t border-gray-100 flex flex-col sm:flex-row justify-between items-center gap-4 px-4 sm:px-0">
<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="hidden sm:block 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

@@ -26,9 +26,9 @@ export default async function BlogPage() {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-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"> <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> </p>
</div> </div>
@@ -40,9 +40,18 @@ export default async function BlogPage() {
) : ( ) : (
<div className="grid gap-8 lg:grid-cols-3"> <div className="grid gap-8 lg:grid-cols-3">
{posts.map(post => ( {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"> <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} /> <img
className="h-full w-full object-cover group-hover:scale-[1.05] transition-transform duration-500"
src={post.imageUrl}
alt={post.title}
style={{
objectPosition: post.coverPosition || '50% 50%',
transform: `scale(${post.coverZoom || 1})`,
transformOrigin: post.coverPosition || '50% 50%'
}}
/>
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div> <div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
</div> </div>
<div className="flex-1 bg-white p-6 flex flex-col justify-between"> <div className="flex-1 bg-white p-6 flex flex-col justify-between">

View File

@@ -5,6 +5,8 @@ import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight
import { sanitizeHTML } from '../../../lib/sanitize'; import { sanitizeHTML } from '../../../lib/sanitize';
import { prisma } from '../../../lib/prisma'; import { prisma } from '../../../lib/prisma';
import { InterviewType } from '@prisma/client'; import { InterviewType } from '@prisma/client';
import LightboxImage from '@/components/LightboxImage';
import BlogShareButtons from '../../../components/BlogShareButtons';
import { Metadata } from 'next'; import { Metadata } from 'next';
@@ -90,13 +92,15 @@ export default async function AfroLifeDetailPage({ params }: Props) {
return ( return (
<div className="bg-white min-h-screen"> <div className="bg-white min-h-screen">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-4xl mx-auto px-0 sm:px-6 lg:px-8 py-0 sm:py-12">
<Link href="/afrolife" className="inline-flex items-center text-gray-500 hover:text-brand-600 mb-8 transition-colors"> <div className="pt-4 sm:pt-0 px-4 sm:px-0 mb-6 sm:mb-8">
<ArrowLeft className="w-4 h-4 mr-2" /> Retour à Afro Life <Link href="/afrolife" className="inline-flex items-center text-gray-500 hover:text-brand-600 transition-colors">
</Link> <ArrowLeft className="w-4 h-4 mr-2" /> Retour à Afro Life
</Link>
</div>
{/* Header Info */} {/* Header Info */}
<div className="text-center mb-10"> <div className="text-center mb-8 sm:mb-10 px-4 sm:px-0">
<div className="inline-flex items-center justify-center px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-xs font-bold uppercase tracking-wider mb-4"> <div className="inline-flex items-center justify-center px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-xs font-bold uppercase tracking-wider mb-4">
{isEvent ? 'Événement' : (isVideo ? 'Interview Vidéo' : 'Grand Entretien')} {isEvent ? 'Événement' : (isVideo ? 'Interview Vidéo' : 'Grand Entretien')}
</div> </div>
@@ -142,9 +146,9 @@ export default async function AfroLifeDetailPage({ params }: Props) {
</div> </div>
</div> </div>
{/* Main Content Area */} {/* Main Content Area en pleine largeur sur mobile */}
{isVideo ? ( {isVideo ? (
<div className="bg-black rounded-xl overflow-hidden shadow-2xl aspect-w-16 aspect-h-9 mb-10"> <div className="bg-black rounded-none sm:rounded-xl overflow-hidden shadow-none sm:shadow-2xl aspect-w-16 aspect-h-9 mb-8 sm:mb-10 w-full">
{(interview as any).videoUrl ? ( {(interview as any).videoUrl ? (
<iframe <iframe
src={getEmbedUrl((interview as any).videoUrl) || ''} src={getEmbedUrl((interview as any).videoUrl) || ''}
@@ -160,14 +164,18 @@ export default async function AfroLifeDetailPage({ params }: Props) {
)} )}
</div> </div>
) : ( ) : (
<div className="relative h-64 md:h-96 rounded-xl overflow-hidden shadow-lg mb-10"> <div className="relative h-64 md:h-96 rounded-none sm:rounded-xl overflow-hidden shadow-none sm:shadow-lg mb-8 sm:mb-10 w-full">
<img src={item!.thumbnailUrl} alt={item!.title} className="w-full h-full object-cover" /> <LightboxImage
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div> src={item!.thumbnailUrl}
alt={item!.title}
position={(item as any).coverPosition || "50% 50%"}
zoom={(item as any).coverZoom || 1}
/>
</div> </div>
)} )}
{/* Description / Article Content / Event Content */} {/* Description / Article Content / Event Content */}
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600"> <div className="prose sm:prose-lg prose-orange max-w-none mx-auto text-gray-600 px-4 sm:px-0">
{isEvent ? ( {isEvent ? (
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} /> <div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} />
) : ( ) : (
@@ -183,7 +191,7 @@ export default async function AfroLifeDetailPage({ params }: Props) {
{/* Event Link Action */} {/* Event Link Action */}
{isEvent && (event as any).link && ( {isEvent && (event as any).link && (
<div className="mt-10 text-center"> <div className="mt-10 text-center px-4 sm:px-0">
<Link <Link
href={(event as any).link} href={(event as any).link}
target="_blank" target="_blank"
@@ -194,12 +202,12 @@ export default async function AfroLifeDetailPage({ params }: Props) {
</div> </div>
)} )}
{/* Footer Actions */} {/* Footer Actions / Partage */}
<div className="mt-12 pt-8 border-t border-gray-100 flex justify-center"> <div className="mt-10 sm:mt-12 pt-6 sm:pt-8 border-t border-gray-100 px-4 sm:px-0 pb-8 sm:pb-0">
<button className="flex items-center px-6 py-3 rounded-full bg-brand-50 text-brand-700 font-medium hover:bg-brand-100 transition-colors"> <p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-4 text-center">
<Share2 className="w-5 h-5 mr-2" />
Partager {isEvent ? "cet événement" : "cette interview"} Partager {isEvent ? "cet événement" : "cette interview"}
</button> </p>
<BlogShareButtons title={item!.title} layout="horizontal" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -149,7 +149,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
<img <img
src={item.thumbnailUrl} src={item.thumbnailUrl}
alt={item.title} alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" className="w-full h-full object-cover group-hover:scale-[1.05] transition-transform duration-700"
style={{
objectPosition: item.coverPosition || '50% 50%',
transform: `scale(${item.coverZoom || 1})`,
transformOrigin: item.coverPosition || '50% 50%'
}}
/> />
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div> <div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>

View File

@@ -17,7 +17,9 @@ export async function POST(request: NextRequest) {
} }
try { try {
const { email, password } = await request.json(); const rawBody = await request.json();
const email = rawBody.email?.toLowerCase().trim();
const password = rawBody.password;
if (!email || !password) { if (!email || !password) {
return NextResponse.json( return NextResponse.json(
@@ -50,8 +52,25 @@ export async function POST(request: NextRequest) {
// Check if email is verified // Check if email is verified
if (!user.emailVerified) { if (!user.emailVerified) {
// En environnement local/développement, on valide automatiquement l'email pour fluidifier les tests
if (process.env.NODE_ENV !== 'production') {
await prisma.user.update({
where: { id: user.id },
data: { emailVerified: true }
});
user.emailVerified = true;
} else {
return NextResponse.json(
{ error: 'Veuillez vérifier votre adresse email avant de vous connecter.' },
{ status: 403 }
);
}
}
// Check if account is marked for deletion
if (user.deletedAt) {
return NextResponse.json( return NextResponse.json(
{ error: 'Veuillez vérifier votre adresse email avant de vous connecter.' }, { error: 'Votre compte est désactivé. Veuillez le réactiver via le lien envoyé par email ou contacter le support.' },
{ status: 403 } { status: 403 }
); );
} }

View File

@@ -18,7 +18,10 @@ export async function POST(request: NextRequest) {
} }
try { try {
const { name, email, password } = await request.json(); const rawBody = await request.json();
const name = rawBody.name;
const email = rawBody.email?.toLowerCase().trim();
const password = rawBody.password;
// Basic validation // Basic validation
if (!name || !email || !password) { if (!name || !email || !password) {
@@ -47,8 +50,8 @@ export async function POST(request: NextRequest) {
); );
} }
// Hash password // Hash password with 10 rounds to match seed and system verification standards
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 10);
// Generate verification token // Generate verification token
const verificationToken = crypto.randomBytes(32).toString('hex'); const verificationToken = crypto.randomBytes(32).toString('hex');
@@ -61,7 +64,7 @@ export async function POST(request: NextRequest) {
password: hashedPassword, password: hashedPassword,
role: 'VISITOR', // Default role role: 'VISITOR', // Default role
verificationToken, verificationToken,
emailVerified: false, emailVerified: process.env.NODE_ENV !== 'production', // Auto-vérifié en développement
} }
}); });
@@ -93,15 +96,57 @@ export async function POST(request: NextRequest) {
.replace(/{siteName}/g, siteName); .replace(/{siteName}/g, siteName);
const { sendEmail } = await import('@/lib/mail'); const { sendEmail } = await import('@/lib/mail');
// Send user verification email
await sendEmail({ await sendEmail({
to: email, to: email,
subject, subject,
html, html,
}); });
// Send admin notification email
try {
const adminEmail = settings?.contactEmail || 'support@afrohub.com';
const adminSubject = `[Admin] Nouvelle inscription sur ${siteName} : ${name}`;
const adminHtml = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #4f46e5; text-align: center;">Notification Admin - Inscription</h2>
<p>Bonjour,</p>
<p>Un nouvel utilisateur vient de s'inscrire sur <strong>${siteName}</strong> :</p>
<table style="width: 100%; border-collapse: collapse; margin-top: 15px;">
<tr>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold; width: 35%;">Nom complet :</td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">${name}</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Adresse email :</td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;"><a href="mailto:${email}">${email}</a></td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Date d'inscription :</td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">${new Date().toLocaleString('fr-FR')}</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Rôle par défaut :</td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">VISITOR</td>
</tr>
</table>
<p style="margin-top: 25px; text-align: center;">
<a href="${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/admin" style="background-color: #4f46e5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">Accéder au Dashboard Admin</a>
</p>
</div>
`;
await sendEmail({
to: adminEmail,
subject: adminSubject,
html: adminHtml,
});
} catch (adminMailError) {
console.error('Failed to send admin signup notification email:', adminMailError);
}
} catch (mailError) { } catch (mailError) {
console.error('Failed to send verification email:', mailError); console.error('Failed to send registration email(s):', mailError);
// We don't fail registration if mail fails, but maybe we should?
// For now, just log it.
} }
// Omit password from response // Omit password from response

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

View File

@@ -0,0 +1,85 @@
"use client";
import React, { useState } from 'react';
import { reactivateUserAccount } from '@/app/actions/auth';
import { toast } from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { Loader2, CheckCircle2 } from 'lucide-react';
export default function ReactivateForm({ userId }: { userId: string }) {
const [reason, setReason] = useState("");
const [isPending, setIsPending] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!reason.trim()) {
toast.error("Merci de préciser la raison");
return;
}
setIsPending(true);
try {
const result = await reactivateUserAccount(userId, reason);
if (result.success) {
setIsSuccess(true);
toast.success("Votre compte a été réactivé !");
setTimeout(() => router.push("/login"), 3000);
} else {
toast.error(result.error || "Une erreur est survenue");
}
} catch (error) {
toast.error("Erreur de connexion");
} finally {
setIsPending(false);
}
};
if (isSuccess) {
return (
<div className="text-center animate-in zoom-in-95 duration-500">
<div className="w-20 h-20 bg-emerald-100 rounded-full flex items-center justify-center text-emerald-500 mx-auto mb-6">
<CheckCircle2 className="w-12 h-12" />
</div>
<h2 className="text-2xl font-bold text-slate-900 mb-2">C'est fait !</h2>
<p className="text-slate-600">
Votre compte est à nouveau actif. Vous allez être redirigé vers la page de connexion dans quelques secondes...
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Pourquoi souhaitez-vous réactiver votre compte ?
</label>
<textarea
required
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Dites-nous ce qui vous a fait changer d'avis..."
className="w-full h-32 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all resize-none text-slate-900"
/>
</div>
<button
type="submit"
disabled={isPending}
className="w-full py-4 bg-indigo-600 text-white rounded-2xl font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all flex items-center justify-center gap-2 disabled:opacity-50"
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
"Réactiver mon compte maintenant"
)}
</button>
<p className="text-center text-xs text-slate-400">
En cliquant sur ce bouton, vous annulez la procédure de suppression en cours.
</p>
</form>
);
}

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { prisma } from '@/lib/prisma';
import ReactivateForm from './ReactivateForm';
import { ShieldCheck, AlertTriangle } from 'lucide-react';
import Link from 'next/link';
export default async function ReactivatePage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ id?: string }> }) {
const searchParams = await searchParamsPromise;
const userId = searchParams.id;
if (!userId) {
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div className="max-w-md w-full bg-white rounded-3xl shadow-xl p-8 text-center">
<AlertTriangle className="w-16 h-16 text-amber-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-slate-900 mb-2">Lien invalide</h1>
<p className="text-slate-600 mb-6">Ce lien de réactivation est incomplet ou expiré.</p>
<Link href="/" className="inline-block px-8 py-3 bg-indigo-600 text-white rounded-xl font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all">
Retour à l'accueil
</Link>
</div>
</div>
);
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, deletedAt: true }
});
if (!user || !user.deletedAt) {
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div className="max-w-md w-full bg-white rounded-3xl shadow-xl p-8 text-center">
<ShieldCheck className="w-16 h-16 text-emerald-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-slate-900 mb-2">Compte déjà actif</h1>
<p className="text-slate-600 mb-6">Votre compte est déjà actif ou n'a jamais é marqué pour suppression.</p>
<Link href="/login" className="inline-block px-8 py-3 bg-indigo-600 text-white rounded-xl font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all">
Se connecter
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div className="max-w-xl w-full bg-white rounded-3xl shadow-xl p-10">
<div className="text-center mb-8">
<div className="w-20 h-20 bg-indigo-100 rounded-2xl flex items-center justify-center text-indigo-600 mx-auto mb-4">
<ShieldCheck className="w-10 h-10" />
</div>
<h1 className="text-3xl font-bold text-slate-900">Réactiver mon compte</h1>
<p className="text-slate-500 mt-2">Heureux de vous revoir, {user.name} !</p>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-5 mb-8">
<p className="text-sm text-amber-800 leading-relaxed">
Votre compte était programmé pour une suppression définitive. En le réactivant, vous conservez toutes vos données, vos messages et vos favoris.
</p>
</div>
<ReactivateForm userId={userId} />
</div>
</div>
);
}

View File

@@ -1,137 +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 { sanitizeHTML } from '../../../lib/sanitize';
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 }
],
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();
}
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">
<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: sanitizeHTML(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

@@ -12,6 +12,7 @@ import DashboardProfileClient from '../../components/dashboard/DashboardProfileC
import DashboardMessages from '../../components/dashboard/DashboardMessages'; import DashboardMessages from '../../components/dashboard/DashboardMessages';
import DashboardOffers from '../../components/dashboard/DashboardOffers'; import DashboardOffers from '../../components/dashboard/DashboardOffers';
import PricingSection from '../../components/PricingSection'; import PricingSection from '../../components/PricingSection';
import OneShotPricingSection from '../../components/OneShotPricingSection';
import DashboardFavorites from '../../components/dashboard/DashboardFavorites'; import DashboardFavorites from '../../components/dashboard/DashboardFavorites';
import { useUser } from '../../components/UserProvider'; import { useUser } from '../../components/UserProvider';
@@ -242,7 +243,7 @@ const DashboardContent = () => {
</button> </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`}> <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`} /> <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> </button>
</> </>
) : ( ) : (
@@ -326,6 +327,7 @@ const DashboardContent = () => {
</span> </span>
</div> </div>
<PricingSection /> <PricingSection />
<OneShotPricingSection />
</div> </div>
)} )}
</div> </div>

View File

@@ -23,16 +23,68 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
font-family: var(--font-sans); font-family: var(--font-sans);
text-align: left;
} }
.prose { /* FORCE ABSOLUE SUR LE CONTENU DU BLOG
text-align: justify; L'utilisation de l'ID #blog-content garantit que ces règles
hyphens: none; écrasent les styles par défaut du plugin typography.
word-break: normal; */
overflow-wrap: normal; #blog-content,
overflow-x: auto; #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 { .prose p {
margin-bottom: 1.25em; margin-bottom: 1.25em;
text-align: left;
}
/* Styles pour les tableaux dans le contenu */
#blog-content table,
.prose table {
width: 100%;
border-collapse: collapse;
margin: 1.5em 0;
font-size: 0.9em;
border: 1px solid #e2e8f0;
}
#blog-content th,
#blog-content td,
.prose th,
.prose td {
border: 1px solid #e2e8f0;
padding: 0.75rem;
text-align: left;
}
#blog-content th,
.prose th {
background-color: #f8fafc;
font-weight: 600;
}
#blog-content tr:nth-child(even),
.prose tr:nth-child(even) {
background-color: #fcfcfc;
} }

View File

@@ -1,8 +1,17 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Home, ArrowLeft, Search } from 'lucide-react'; import { Home, Search } from 'lucide-react';
import { getSiteSettings } from '../lib/settings';
export default async function NotFound() {
const settings = await getSiteSettings();
const quotes = (settings as any)?.notFoundQuotes && (settings as any).notFoundQuotes.length > 0
? (settings as any).notFoundQuotes
: ["L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."];
// Sélection d'une phrase aléatoire
const randomQuote = quotes[Math.floor(Math.random() * quotes.length)];
export default function NotFound() {
return ( 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="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"> <div className="max-w-md w-full text-center">
@@ -41,10 +50,11 @@ export default function NotFound() {
<div className="mt-12 pt-12 border-t border-gray-100"> <div className="mt-12 pt-12 border-t border-gray-100">
<p className="text-sm text-gray-400 italic"> <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." "{randomQuote}"
</p> </p>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -44,9 +44,9 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function HomePage() { export default async function HomePage() {
// Fetch initial data for faster loading and SEO // Fetch initial data for faster loading and SEO
const [featuredBusinesses, posts, categories] = await Promise.all([ const [featuredBusinesses, posts, categories, slides] = await Promise.all([
prisma.business.findMany({ prisma.business.findMany({
where: { isFeatured: true, isActive: true, isSuspended: false }, where: { isHomeFeatured: true, isActive: true, isSuspended: false },
take: 4, take: 4,
include: { categoryRef: true } include: { categoryRef: true }
}), }),
@@ -58,6 +58,11 @@ export default async function HomePage() {
prisma.businessCategory.findMany({ prisma.businessCategory.findMany({
where: { isActive: true }, where: { isActive: true },
take: 8 take: 8
}),
prisma.homeSlide.findMany({
where: { isActive: true },
orderBy: { order: 'asc' },
include: { business: true }
}) })
]); ]);
@@ -65,12 +70,14 @@ export default async function HomePage() {
const initialFeatured = JSON.parse(JSON.stringify(featuredBusinesses)); const initialFeatured = JSON.parse(JSON.stringify(featuredBusinesses));
const initialPosts = JSON.parse(JSON.stringify(posts)); const initialPosts = JSON.parse(JSON.stringify(posts));
const initialCategories = JSON.parse(JSON.stringify(categories)); const initialCategories = JSON.parse(JSON.stringify(categories));
const initialSlides = JSON.parse(JSON.stringify(slides));
return ( return (
<HomeClient <HomeClient
initialFeatured={initialFeatured} initialFeatured={initialFeatured}
initialPosts={initialPosts} initialPosts={initialPosts}
initialCategories={initialCategories} initialCategories={initialCategories}
initialSlides={initialSlides}
/> />
); );
} }

View File

@@ -3,6 +3,7 @@
import React from 'react'; import React from 'react';
import PricingSection from '../../components/PricingSection'; import PricingSection from '../../components/PricingSection';
import OneShotPricingSection from '../../components/OneShotPricingSection';
const SubscriptionPage = () => { const SubscriptionPage = () => {
return ( return (
@@ -12,6 +13,7 @@ const SubscriptionPage = () => {
<p className="text-gray-400 mt-2">Rejoignez la plus grande communauté d'entrepreneurs.</p> <p className="text-gray-400 mt-2">Rejoignez la plus grande communauté d'entrepreneurs.</p>
</div> </div>
<PricingSection /> <PricingSection />
<OneShotPricingSection />
{/* FAQ Section */} {/* FAQ Section */}
<div className="max-w-4xl mx-auto px-4 py-16"> <div className="max-w-4xl mx-auto px-4 py-16">

13
check-posts.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const posts = await prisma.blogPost.findMany({
orderBy: { createdAt: 'desc' },
take: 5
});
console.log(JSON.stringify(posts, null, 2));
}
main().catch(console.error).finally(() => prisma.$disconnect());

31
check_db.cjs Normal file
View File

@@ -0,0 +1,31 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const post = await prisma.blogPost.findUnique({
where: { slug: 'cyber-dome-cato' }
});
if (!post) {
console.log("Post not found");
return;
}
const snippet = post.content.substring(0, 1000);
console.log("Content start:");
console.log(snippet);
const match = post.content.match(/s.e sont/);
if (match) {
console.log("Found 's.e sont':", match[0]);
console.log("Hex:", Buffer.from(match[0]).toString('hex'));
}
const match2 = post.content.match(/cyberattaqu.es/);
if (match2) {
console.log("Found 'cyberattaqu.es':", match2[0]);
console.log("Hex:", Buffer.from(match2[0]).toString('hex'));
}
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@@ -56,9 +56,11 @@ const AnnuaireHero = ({ featuredBusiness }: { featuredBusiness: Business }) => {
<h2 className="text-3xl md:text-4xl font-bold font-serif text-gray-900 mb-2"> <h2 className="text-3xl md:text-4xl font-bold font-serif text-gray-900 mb-2">
{featuredBusiness.name} {featuredBusiness.name}
</h2> </h2>
<p className="text-lg text-gray-600 mb-4 font-medium"> {featuredBusiness.founderName && (
Dirigé par <span className="text-brand-600">{featuredBusiness.founderName}</span> <p className="text-lg text-gray-600 mb-4 font-medium">
</p> Dirigé par <span className="text-brand-600">{featuredBusiness.founderName}</span>
</p>
)}
<div className="flex flex-wrap gap-4 text-sm text-gray-500 mb-6"> <div className="flex flex-wrap gap-4 text-sm text-gray-500 mb-6">
<div className="flex items-center"><Briefcase className="w-4 h-4 mr-1"/> {featuredBusiness.category}</div> <div className="flex items-center"><Briefcase className="w-4 h-4 mr-1"/> {featuredBusiness.category}</div>

View File

@@ -0,0 +1,74 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Facebook, Instagram, Twitter, Link as LinkIcon, Check } from 'lucide-react';
import toast from 'react-hot-toast';
interface Props {
title: string;
layout?: 'vertical' | 'horizontal';
}
export default function BlogShareButtons({ title, layout = 'vertical' }: Props) {
const [copied, setCopied] = useState(false);
const [url, setUrl] = useState('');
useEffect(() => {
setUrl(window.location.href);
}, []);
const handleCopy = (customMessage?: string) => {
if (!url) return;
navigator.clipboard.writeText(url);
setCopied(true);
toast.success(customMessage || 'Lien copié dans le presse-papiers !');
setTimeout(() => setCopied(false), 2000);
};
const shareLinks = {
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
twitter: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`,
};
if (!url) return null;
return (
<div className={`flex ${layout === 'horizontal' ? 'flex-row gap-4 justify-center flex-wrap' : 'flex-col gap-3'} z-50`}>
<a
href={shareLinks.facebook}
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 rounded-full bg-white text-[#1877F2] shadow-xl flex items-center justify-center hover:scale-110 transition-transform border border-gray-100"
title="Partager sur Facebook"
>
<Facebook className="w-5 h-5" />
</a>
<a
href={shareLinks.twitter}
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 rounded-full bg-white text-black shadow-xl flex items-center justify-center hover:scale-110 transition-transform border border-gray-100"
title="Partager sur X (Twitter)"
>
<Twitter className="w-5 h-5" />
</a>
<button
onClick={() => handleCopy('Lien copié ! Vous pouvez le coller sur Instagram.')}
className="w-10 h-10 rounded-full bg-gradient-to-tr from-[#f9ce34] via-[#ee2a7b] to-[#6228d7] text-white shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
title="Copier le lien pour Instagram"
>
<Instagram className="w-5 h-5" />
</button>
<button
onClick={() => handleCopy()}
className="w-10 h-10 rounded-full bg-gray-800 text-white shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
title="Copier le lien"
>
{copied ? <Check className="w-5 h-5 text-green-400" /> : <LinkIcon className="w-5 h-5" />}
</button>
</div>
);
}

View File

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

220
components/HomeSlider.tsx Normal file
View File

@@ -0,0 +1,220 @@
"use client";
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { Search, MapPin, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
interface HomeSlide {
id: string;
type: 'BUSINESS' | 'CUSTOM';
businessId?: string | null;
business?: any;
title?: string | null;
subtitle?: string | null;
imageUrl?: string | null;
linkUrl?: string | null;
coverPosition?: string | null;
coverZoom?: number | null;
}
interface Props {
slides: HomeSlide[];
onSearch: (searchTerm: string, locationTerm: string) => void;
}
const HomeSlider = ({ slides, onSearch }: Props) => {
const [current, setCurrent] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [locationTerm, setLocationTerm] = useState('');
// Auto-slide every 6 seconds
useEffect(() => {
if (slides.length <= 1) return;
const interval = setInterval(() => {
setCurrent((prev) => (prev === slides.length - 1 ? 0 : prev + 1));
}, 6000);
return () => clearInterval(interval);
}, [slides.length]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(searchTerm, locationTerm);
};
// If no slides, show default hero
if (slides.length === 0) {
return (
<div className="relative bg-dark-900 overflow-hidden min-h-[600px] flex items-center">
<div className="absolute inset-0 opacity-40">
<img
src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80"
className="w-full h-full object-cover"
alt="Default Banner"
/>
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32 w-full">
<div className="max-w-3xl">
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight animate-in fade-in slide-in-from-left-4 duration-700">
Boostez votre visibilité dans <br />
<span className="text-brand-500">l'écosystème africain</span>
</h1>
<p className="text-xl text-gray-300 mb-8 max-w-2xl animate-in fade-in slide-in-from-left-4 duration-700 delay-150">
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
</p>
<SearchForm
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
locationTerm={locationTerm}
setLocationTerm={setLocationTerm}
onSubmit={handleSearchSubmit}
/>
</div>
</div>
</div>
);
}
return (
<div className="relative h-[650px] md:h-[700px] overflow-hidden bg-black">
{slides.map((slide, index) => (
<div
key={slide.id}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
index === current ? 'opacity-100 z-10' : 'opacity-0 z-0'
}`}
>
{/* Background Image */}
<div className="absolute inset-0">
<img
src={slide.imageUrl || slide.business?.coverUrl || slide.business?.logoUrl || ''}
alt=""
style={{
objectPosition: slide.type === 'BUSINESS' ? (slide.business?.coverPosition || '50% 50%') : (slide.coverPosition || '50% 50%'),
transformOrigin: slide.type === 'BUSINESS' ? (slide.business?.coverPosition || '50% 50%') : (slide.coverPosition || '50% 50%'),
'--base-zoom': slide.type === 'BUSINESS' ? (slide.business?.coverZoom || 1) : (slide.coverZoom || 1)
} as any}
className={`w-full h-full object-cover transition-transform duration-[6000ms] ease-linear ${
index === current ? 'scale-[calc(var(--base-zoom)*1.1)]' : 'scale-[var(--base-zoom)]'
}`}
/>
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/40 to-transparent" />
</div>
{/* Content */}
<div className="relative h-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center">
<div className={`max-w-3xl transition-all duration-700 delay-300 ${
index === current ? 'translate-x-0 opacity-100' : '-translate-x-10 opacity-0'
}`}>
{slide.type === 'BUSINESS' && (
<div className="inline-flex items-center gap-2 px-3 py-1 bg-brand-600/20 border border-brand-500/30 rounded-full mb-6">
<span className="w-2 h-2 bg-brand-500 rounded-full animate-pulse" />
<span className="text-brand-400 text-xs font-bold uppercase tracking-widest">Entreprise à la une</span>
</div>
)}
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight leading-tight">
{slide.type === 'BUSINESS' ? (
<>Découvrez <span className="text-brand-500">{slide.business?.name}</span></>
) : (
slide.title
)}
</h1>
<p className="text-xl text-gray-300 mb-8 max-w-2xl line-clamp-3">
{slide.type === 'BUSINESS' ? (
slide.business?.description
) : (
slide.subtitle
)}
</p>
<div className="flex flex-wrap gap-4 items-center">
<Link
href={slide.type === 'BUSINESS' ? `/annuaire/${slide.business?.id}` : (slide.linkUrl || '#')}
className="inline-flex items-center gap-2 bg-brand-600 text-white px-8 py-4 rounded-lg font-bold hover:bg-brand-700 transition-all shadow-lg shadow-brand-600/20 hover:scale-105"
>
{slide.type === 'BUSINESS' ? 'Voir la fiche' : 'En savoir plus'}
<ArrowRight className="w-5 h-5" />
</Link>
</div>
</div>
</div>
</div>
))}
{/* Static Search Overlay (Always visible on top of slides) */}
<div className="absolute bottom-12 md:bottom-20 left-0 right-0 z-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<SearchForm
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
locationTerm={locationTerm}
setLocationTerm={setLocationTerm}
onSubmit={handleSearchSubmit}
/>
</div>
</div>
{/* Navigation Arrows */}
{slides.length > 1 && (
<>
<button
onClick={() => setCurrent(current === 0 ? slides.length - 1 : current - 1)}
className="absolute left-4 top-1/2 -translate-y-1/2 z-30 p-3 rounded-full bg-white/10 text-white hover:bg-white/20 transition-all backdrop-blur-sm"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
onClick={() => setCurrent(current === slides.length - 1 ? 0 : current + 1)}
className="absolute right-4 top-1/2 -translate-y-1/2 z-30 p-3 rounded-full bg-white/10 text-white hover:bg-white/20 transition-all backdrop-blur-sm"
>
<ChevronRight className="w-6 h-6" />
</button>
{/* Indicators */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-30 flex gap-2">
{slides.map((_, i) => (
<button
key={i}
onClick={() => setCurrent(i)}
className={`w-2.5 h-2.5 rounded-full transition-all ${
i === current ? 'bg-brand-500 w-8' : 'bg-white/40'
}`}
/>
))}
</div>
</>
)}
</div>
);
};
const SearchForm = ({ searchTerm, setSearchTerm, locationTerm, setLocationTerm, onSubmit }: any) => (
<form onSubmit={onSubmit} className="max-w-4xl bg-white/95 backdrop-blur-md p-2 rounded-xl shadow-2xl flex flex-col md:flex-row gap-2 border border-white/20 animate-in fade-in slide-in-from-bottom-4 duration-700 delay-500">
<div className="flex-1 relative">
<Search className="absolute left-3 top-3.5 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Que recherchez-vous ? (ex: Traiteur, Photographe...)"
className="w-full pl-10 pr-4 py-3.5 rounded-lg focus:outline-none text-gray-900 font-medium"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-100">
<MapPin className="absolute left-3 top-3.5 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Où ? (ex: Dakar)"
className="w-full pl-10 pr-4 py-3.5 rounded-lg focus:outline-none text-gray-900 font-medium"
value={locationTerm}
onChange={(e) => setLocationTerm(e.target.value)}
/>
</div>
<button type="submit" className="bg-brand-600 text-white px-10 py-3.5 rounded-lg font-bold hover:bg-brand-700 transition-all hover:scale-105 active:scale-95 shadow-lg shadow-brand-600/30">
Rechercher
</button>
</form>
);
export default HomeSlider;

View File

@@ -0,0 +1,99 @@
'use client';
import React, { useState, useEffect } from 'react';
import { X, Maximize2 } from 'lucide-react';
interface LightboxImageProps {
src: string;
alt: string;
position?: string;
zoom?: number;
className?: string;
}
export default function LightboxImage({
src,
alt,
position = "50% 50%",
zoom = 1,
className = "w-full h-full object-cover"
}: LightboxImageProps) {
const [isOpen, setIsOpen] = useState(false);
// Close on Escape key
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false);
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, []);
// Prevent scroll when open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => { document.body.style.overflow = 'unset'; };
}, [isOpen]);
return (
<>
<div
className="relative group cursor-zoom-in w-full h-full overflow-hidden"
onClick={() => setIsOpen(true)}
>
<img
src={src}
alt={alt}
className={className}
style={{
objectPosition: position,
transform: `scale(${zoom})`,
transformOrigin: position
}}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100">
<div className="bg-white/20 backdrop-blur-md p-3 rounded-full text-white scale-90 group-hover:scale-100 transition-all">
<Maximize2 className="w-6 h-6" />
</div>
</div>
</div>
{/* Lightbox Overlay */}
{isOpen && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center p-4 md:p-12 animate-in fade-in duration-300"
onClick={() => setIsOpen(false)}
>
<button
className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors z-[10000]"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
>
<X className="w-6 h-6" />
</button>
<div
className="relative w-full h-full flex items-center justify-center cursor-zoom-out"
>
<img
src={src}
alt={alt}
className="max-w-full max-h-full object-contain shadow-2xl animate-in zoom-in-95 duration-300"
/>
{/* Legend / Caption */}
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/80 to-transparent text-white text-center">
<p className="text-lg font-medium">{alt}</p>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -42,8 +42,8 @@ const Navbar = ({ settings }: NavbarProps) => {
<Link href="/afrolife" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/afrolife') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-brand-600') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}> <Link href="/afrolife" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/afrolife') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-brand-600') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
Afro Life Afro Life
</Link> </Link>
<Link href="/blog" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/blog') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}> <Link href="/actualites" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/actualites') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
Blog Actualités
</Link> </Link>
</div> </div>
</div> </div>
@@ -77,7 +77,7 @@ const Navbar = ({ settings }: NavbarProps) => {
<Link href="/" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Accueil</Link> <Link href="/" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Accueil</Link>
<Link href="/annuaire" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Annuaire</Link> <Link href="/annuaire" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Annuaire</Link>
<Link href="/afrolife" className="block pl-3 pr-4 py-2 border-l-4 border-brand-500 text-base font-medium text-brand-700 bg-brand-50" onClick={() => setIsOpen(false)}>Afro Life</Link> <Link href="/afrolife" className="block pl-3 pr-4 py-2 border-l-4 border-brand-500 text-base font-medium text-brand-700 bg-brand-50" onClick={() => setIsOpen(false)}>Afro Life</Link>
<Link href="/blog" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Blog</Link> <Link href="/actualites" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Actualités</Link>
{user ? ( {user ? (
<Link href="/dashboard" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Mon Espace</Link> <Link href="/dashboard" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Mon Espace</Link>
) : ( ) : (

View File

@@ -0,0 +1,251 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Zap, Calendar, Newspaper, Layout, Check, Smartphone, CreditCard, Loader, ShieldCheck, X } from 'lucide-react';
import { useUser } from './UserProvider';
import { useRouter } from 'next/navigation';
import CreateEventModal from './dashboard/CreateEventModal';
import CreateNewsModal from './dashboard/CreateNewsModal';
import { toast } from 'react-hot-toast';
interface OneShotPlan {
id: string;
type: string;
name: string;
priceXOF: number;
priceEUR: number;
description: string | null;
}
const TYPE_ICONS = {
BUMP: Zap,
POST_EVENT: Calendar,
POST_NEWS: Newspaper,
AD_DIRECTORY: Layout,
};
const OneShotPricingSection = () => {
const [plans, setPlans] = useState<OneShotPlan[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPlan, setSelectedPlan] = useState<OneShotPlan | null>(null);
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
const [paymentStep, setPaymentStep] = useState<'method' | 'processing' | 'success'>('method');
const [paymentMethod, setPaymentMethod] = useState<'mobile_money' | 'card' | null>(null);
const [showEventModal, setShowEventModal] = useState(false);
const [showNewsModal, setShowNewsModal] = useState(false);
const { user } = useUser();
const router = useRouter();
useEffect(() => {
const fetchPlans = async () => {
try {
const res = await fetch('/api/one-shot-plans');
const data = await res.json();
if (!data.error) {
setPlans(data);
}
} catch (error) {
console.error('Error fetching one-shot plans:', error);
} finally {
setLoading(false);
}
};
fetchPlans();
}, []);
const handleSelectPlan = (plan: OneShotPlan) => {
if (!user) {
toast.error("Veuillez vous connecter pour commander un service");
router.push('/login?callback=/subscription');
return;
}
setSelectedPlan(plan);
setPaymentStep('method');
setIsPaymentModalOpen(true);
};
const handlePayment = () => {
setPaymentStep('processing');
setTimeout(() => {
setPaymentStep('success');
}, 2500);
};
const closePayment = () => {
setIsPaymentModalOpen(false);
setPaymentStep('method');
setSelectedPlan(null);
setPaymentMethod(null);
};
if (!loading && plans.length === 0) return null;
return (
<div className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-base font-semibold text-brand-600 tracking-wide uppercase">Services à la carte</h2>
<p className="mt-1 text-3xl font-extrabold text-gray-900 sm:text-4xl font-serif">
Boostez votre visibilité ponctuellement
</p>
<p className="max-w-xl mt-4 mx-auto text-lg text-gray-500">
Des options flexibles pour mettre en avant vos actualités et vos offres sans engagement.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{loading ? (
[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-gray-50 rounded-2xl p-6 h-64 animate-pulse border border-gray-100">
<div className="w-12 h-12 bg-gray-200 rounded-xl mb-4"></div>
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-8"></div>
<div className="h-10 bg-gray-200 rounded w-full"></div>
</div>
))
) : (
plans.map((plan) => {
const Icon = TYPE_ICONS[plan.type as keyof typeof TYPE_ICONS] || Zap;
return (
<div key={plan.id} className="bg-gray-50 rounded-2xl p-8 border border-gray-100 hover:border-brand-200 hover:shadow-lg transition-all group flex flex-col">
<div className="w-14 h-14 bg-brand-50 rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
<Icon className="w-8 h-8 text-brand-600" />
</div>
<h3 className="text-xl font-bold text-gray-900 font-serif mb-2">{plan.name}</h3>
<p className="text-sm text-gray-500 mb-6 flex-1">{plan.description}</p>
<div className="mb-6">
<span className="text-3xl font-black text-gray-900">{plan.priceXOF.toLocaleString()} F CFA</span>
<p className="text-xs text-gray-400 mt-1">env. {plan.priceEUR} </p>
</div>
<button
onClick={() => handleSelectPlan(plan)}
className="w-full bg-white border-2 border-brand-600 text-brand-600 hover:bg-brand-600 hover:text-white font-bold py-3 rounded-xl transition-all shadow-sm"
>
Commander
</button>
</div>
);
})
)}
</div>
</div>
{/* PAYMENT MODAL (Simplified copy from PricingSection) */}
{isPaymentModalOpen && selectedPlan && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4">
<div className="fixed inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity" onClick={closePayment}></div>
<div className="relative bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all max-w-lg w-full">
<div className="px-6 py-8">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-gray-900 font-serif">Paiement Service Ponctuel</h3>
<button onClick={closePayment} className="text-gray-400 hover:text-gray-600"><X className="h-6 w-6" /></button>
</div>
{paymentStep === 'method' && (
<>
<div className="bg-brand-50 p-5 rounded-2xl mb-8 border border-brand-100">
<p className="text-sm text-brand-800 font-medium">Option : <span className="font-bold">{selectedPlan.name}</span></p>
<p className="text-3xl font-black text-brand-900 mt-1">{selectedPlan.priceXOF.toLocaleString()} F CFA</p>
</div>
<div className="space-y-3">
<button
onClick={() => setPaymentMethod('mobile_money')}
className={`w-full flex items-center p-4 border-2 rounded-xl transition-all ${paymentMethod === 'mobile_money' ? 'border-brand-500 bg-brand-50/50' : 'border-gray-100 hover:bg-gray-50'}`}
>
<div className="h-10 w-10 bg-orange-100 rounded-xl flex items-center justify-center text-orange-600 mr-4"><Smartphone /></div>
<div className="text-left flex-1">
<p className="font-bold text-gray-900">Mobile Money</p>
<p className="text-xs text-gray-500">Orange, MTN, Wave, Moov</p>
</div>
</button>
<button
onClick={() => setPaymentMethod('card')}
className={`w-full flex items-center p-4 border-2 rounded-xl transition-all ${paymentMethod === 'card' ? 'border-brand-500 bg-brand-50/50' : 'border-gray-100 hover:bg-gray-50'}`}
>
<div className="h-10 w-10 bg-blue-100 rounded-xl flex items-center justify-center text-blue-600 mr-4"><CreditCard /></div>
<div className="text-left flex-1">
<p className="font-bold text-gray-900">Carte Bancaire</p>
<p className="text-xs text-gray-500">Visa, Mastercard</p>
</div>
</button>
</div>
<button
disabled={!paymentMethod}
onClick={handlePayment}
className="w-full mt-8 bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 disabled:opacity-50 transition-all"
>
Payer maintenant
</button>
</>
)}
{paymentStep === 'processing' && (
<div className="py-12 text-center">
<Loader className="h-14 w-14 text-brand-600 animate-spin mx-auto mb-6" />
<p className="text-xl font-bold text-gray-900">Sécurisation du paiement...</p>
<p className="text-gray-500 mt-2">N'interrompez pas la transaction.</p>
</div>
)}
{paymentStep === 'success' && (
<div className="py-8 text-center animate-in zoom-in duration-300">
<div className="mx-auto flex items-center justify-center h-20 w-20 rounded-full bg-green-100 mb-6">
<ShieldCheck className="h-12 w-12 text-green-600" />
</div>
<h3 className="text-2xl font-bold text-gray-900">Paiement Réussi !</h3>
<p className="text-gray-500 mt-3 mb-8">
{selectedPlan.type === 'POST_EVENT' || selectedPlan.type === 'POST_NEWS'
? "Votre paiement a été validé. Vous pouvez maintenant remplir le formulaire de votre publication."
: `Votre service ${selectedPlan.name} a été activé avec succès.`
}
</p>
{selectedPlan.type === 'POST_EVENT' || selectedPlan.type === 'POST_NEWS' ? (
<button
onClick={() => {
setIsPaymentModalOpen(false);
if (selectedPlan.type === 'POST_EVENT') setShowEventModal(true);
if (selectedPlan.type === 'POST_NEWS') setShowNewsModal(true);
}}
className="w-full bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 transition-all flex items-center justify-center gap-2"
>
{selectedPlan.type === 'POST_EVENT' ? <Calendar className="w-5 h-5" /> : <Newspaper className="w-5 h-5" />}
Remplir le formulaire
</button>
) : (
<button onClick={closePayment} className="w-full bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 transition-all">Retourner au site</button>
)}
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* EVENT FORM MODAL */}
<CreateEventModal
isOpen={showEventModal}
onClose={() => {
setShowEventModal(false);
closePayment();
}}
/>
{/* NEWS FORM MODAL */}
<CreateNewsModal
isOpen={showNewsModal}
onClose={() => {
setShowNewsModal(false);
closePayment();
}}
/>
</div>
);
};
export default OneShotPricingSection;

View File

@@ -0,0 +1,229 @@
"use client";
import React, { useState, useTransition } from 'react';
import { X, Calendar, MapPin, Link as LinkIcon, Image as ImageIcon, AlertTriangle, Loader2 } from 'lucide-react';
import { createEvent } from '@/app/actions/events';
import { uploadImage } from '@/app/actions/upload';
import { toast } from 'react-hot-toast';
interface CreateEventModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function CreateEventModal({ isOpen, onClose }: CreateEventModalProps) {
const [isPending, startTransition] = useTransition();
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = {
title: formData.get('title') as string,
description: formData.get('description') as string,
date: formData.get('date') as string,
location: formData.get('location') as string,
thumbnailUrl: imagePreview || "https://picsum.photos/800/400?random=event",
link: formData.get('link') as string || undefined,
};
if (!data.title || !data.description || !data.date || !data.location) {
toast.error("Veuillez remplir tous les champs obligatoires");
return;
}
startTransition(async () => {
const result = await createEvent(data);
if (result.success) {
toast.success("Événement soumis avec succès !");
onClose();
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
const result = await uploadImage(formData);
if (result.success && result.url) {
setImagePreview(result.url);
} else {
toast.error(result.error || "Erreur lors de l'upload");
}
setUploading(false);
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-300">
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-brand-50/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-brand-100 rounded-xl text-brand-600">
<Calendar className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">Publier un événement</h2>
<p className="text-xs text-gray-500">Partagez votre actualité avec la communauté</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6 max-h-[80vh] overflow-y-auto">
{/* Warning */}
<div className="flex gap-4 p-4 bg-amber-50 border border-amber-200 rounded-2xl">
<div className="shrink-0 p-2 bg-amber-100 rounded-xl text-amber-600">
<AlertTriangle className="w-5 h-5" />
</div>
<div>
<h4 className="text-sm font-bold text-amber-800">Information importante</h4>
<p className="text-xs text-amber-700 leading-relaxed">
Votre événement sera soumis à la validation de notre équipe de modération avant d'être publié sur la plateforme (généralement sous 24h).
</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Titre de l'événement *</label>
<input
name="title"
required
placeholder="Ex: Conférence sur l'Agrotech"
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Date *</label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
name="date"
type="date"
required
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Lieu *</label>
<div className="relative">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
name="location"
required
placeholder="Ex: Abidjan, Côte d'Ivoire"
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Description *</label>
<textarea
name="description"
required
rows={4}
placeholder="Présentez votre événement en quelques lignes..."
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all resize-none"
></textarea>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Affiche / Photo de l'événement</label>
<div
className={`relative border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-8 transition-all ${
imagePreview ? 'border-brand-500 bg-brand-50' : 'border-gray-200 hover:border-brand-300 bg-gray-50'
}`}
>
{imagePreview ? (
<div className="relative w-full aspect-video rounded-lg overflow-hidden shadow-inner">
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => setImagePreview(null)}
className="absolute top-2 right-2 p-1 bg-white/80 rounded-full hover:bg-white text-gray-600 shadow-lg"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<>
{uploading ? (
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
) : (
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
)}
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
<p className="text-xs text-gray-400 mt-1">PNG, JPG, JPEG jusqu'à 5Mo</p>
<input
type="file"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={handleImageChange}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
</>
)}
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Lien externe (facultatif)</label>
<div className="relative">
<LinkIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
name="link"
placeholder="https://votre-billetterie.com"
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
/>
</div>
</div>
</div>
<div className="flex items-center gap-4 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-6 py-4 rounded-2xl font-bold text-gray-600 hover:bg-gray-100 transition-all"
>
Annuler
</button>
<button
type="submit"
disabled={isPending || uploading}
className="flex-[2] bg-brand-600 hover:bg-brand-700 disabled:bg-gray-200 disabled:cursor-not-allowed text-white px-6 py-4 rounded-2xl font-bold shadow-xl shadow-brand-200 transition-all flex items-center justify-center gap-2"
>
{isPending ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Soumission...
</>
) : (
<>
<Calendar className="w-5 h-5" />
Soumettre l'événement
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import React, { useState, useTransition } from 'react';
import { X, Newspaper, User, Image as ImageIcon, AlertTriangle, Loader2, AlignLeft } from 'lucide-react';
import { createNews } from '@/app/actions/news';
import { uploadImage } from '@/app/actions/upload';
import { toast } from 'react-hot-toast';
interface CreateNewsModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function CreateNewsModal({ isOpen, onClose }: CreateNewsModalProps) {
const [isPending, startTransition] = useTransition();
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = {
title: formData.get('title') as string,
excerpt: formData.get('excerpt') as string,
content: formData.get('content') as string,
author: formData.get('author') as string,
imageUrl: imagePreview || "https://picsum.photos/800/400?random=news",
};
if (!data.title || !data.excerpt || !data.content || !data.author) {
toast.error("Veuillez remplir tous les champs obligatoires");
return;
}
startTransition(async () => {
const result = await createNews(data);
if (result.success) {
toast.success("Actualité soumise avec succès !");
onClose();
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
const result = await uploadImage(formData);
if (result.success && result.url) {
setImagePreview(result.url);
} else {
toast.error(result.error || "Erreur lors de l'upload");
}
setUploading(false);
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-300">
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-indigo-50/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 rounded-xl text-indigo-600">
<Newspaper className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">Publier une actualité</h2>
<p className="text-xs text-gray-500">Mettez en avant vos succès et nouveautés</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6 max-h-[80vh] overflow-y-auto">
{/* Warning */}
<div className="flex gap-4 p-4 bg-amber-50 border border-amber-200 rounded-2xl">
<div className="shrink-0 p-2 bg-amber-100 rounded-xl text-amber-600">
<AlertTriangle className="w-5 h-5" />
</div>
<div>
<h4 className="text-sm font-bold text-amber-800">Information importante</h4>
<p className="text-xs text-amber-700 leading-relaxed">
Votre article sera soumis à la validation de notre équipe de modération avant d'être publié dans la section Actualités.
</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Titre de l'article *</label>
<input
name="title"
required
placeholder="Ex: Lancement de notre nouvelle gamme de produits"
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Auteur *</label>
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
name="author"
required
placeholder="Votre nom ou nom d'entreprise"
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Résumé court (Excerpt) *</label>
<div className="relative">
<AlignLeft className="absolute left-4 top-4 w-4 h-4 text-gray-400" />
<textarea
name="excerpt"
required
rows={2}
placeholder="Un court résumé qui apparaîtra dans la liste des articles..."
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all resize-none"
></textarea>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Contenu de l'article *</label>
<textarea
name="content"
required
rows={6}
placeholder="Racontez votre histoire..."
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all resize-none"
></textarea>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Image de couverture</label>
<div
className={`relative border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-8 transition-all ${
imagePreview ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-indigo-300 bg-gray-50'
}`}
>
{imagePreview ? (
<div className="relative w-full aspect-video rounded-lg overflow-hidden shadow-inner">
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => setImagePreview(null)}
className="absolute top-2 right-2 p-1 bg-white/80 rounded-full hover:bg-white text-gray-600 shadow-lg"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<>
{uploading ? (
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
) : (
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
)}
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
<p className="text-xs text-gray-400 mt-1">PNG, JPG, JPEG jusqu'à 5Mo</p>
<input
type="file"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={handleImageChange}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-4 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-6 py-4 rounded-2xl font-bold text-gray-600 hover:bg-gray-100 transition-all"
>
Annuler
</button>
<button
type="submit"
disabled={isPending || uploading}
className="flex-[2] bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-200 disabled:cursor-not-allowed text-white px-6 py-4 rounded-2xl font-bold shadow-xl shadow-indigo-200 transition-all flex items-center justify-center gap-2"
>
{isPending ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Soumission...
</>
) : (
<>
<Newspaper className="w-5 h-5" />
Soumettre l'actualité
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -53,16 +53,33 @@ const DashboardMessages = ({ onMessagesRead, initialConversationId }: DashboardM
const [isMobileListVisible, setIsMobileListVisible] = useState(true); const [isMobileListVisible, setIsMobileListVisible] = useState(true);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const initialSelectionDone = useRef(false);
useEffect(() => { useEffect(() => {
fetchConversations(); if (user?.id) {
}, []); fetchConversations();
useEffect(() => { // Polling automatique de la liste des conversations toutes les 5 secondes
if (selectedConversation) { const interval = setInterval(() => {
fetchMessages(selectedConversation.id); fetchConversations();
}, 5000);
return () => clearInterval(interval);
} }
}, [selectedConversation]); }, [user?.id]);
useEffect(() => {
if (selectedConversation?.id) {
fetchMessages(selectedConversation.id);
// Polling des messages toutes les 2.5 secondes pour une messagerie instantanée fluide
const interval = setInterval(() => {
fetchMessages(selectedConversation.id);
}, 2500);
return () => clearInterval(interval);
}
}, [selectedConversation?.id]);
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
@@ -81,12 +98,13 @@ const DashboardMessages = ({ onMessagesRead, initialConversationId }: DashboardM
if (!data.error) { if (!data.error) {
setConversations(data); setConversations(data);
// Handle initial auto-selection // Handle initial auto-selection une seule fois
if (initialConversationId) { if (initialConversationId && !initialSelectionDone.current) {
const found = data.find((c: Conversation) => c.id === initialConversationId); const found = data.find((c: Conversation) => c.id === initialConversationId);
if (found) { if (found) {
setSelectedConversation(found); setSelectedConversation(found);
setIsMobileListVisible(false); setIsMobileListVisible(false);
initialSelectionDone.current = true;
} }
} }
} }
@@ -104,10 +122,18 @@ const DashboardMessages = ({ onMessagesRead, initialConversationId }: DashboardM
}); });
const data = await res.json(); const data = await res.json();
if (!data.error) { if (!data.error) {
setMessages(data); setMessages(prev => {
// After fetching messages (which marks them as read in the backend), const hasChanged = prev.length !== data.length ||
// notify the parent to refresh the unread count badge (prev.length > 0 && data.length > 0 && prev[prev.length - 1].id !== data[data.length - 1].id);
if (onMessagesRead) onMessagesRead();
if (hasChanged) {
setTimeout(() => {
if (onMessagesRead) onMessagesRead();
}, 0);
return data;
}
return prev;
});
} }
} catch (error) { } catch (error) {
console.error('Error fetching messages:', error); console.error('Error fetching messages:', error);

View File

@@ -0,0 +1,38 @@
# Guide de Gestion des Utilisateurs (Suspension & Suppression)
Ce document détaille les procédures de sanctions et de gestion des comptes sur Afrohub.
## 1. Suspension de Compte
La suspension est une mesure temporaire permettant de bloquer l'accès d'un utilisateur sans supprimer ses données.
### 1.1. Suspension Entrepreneur
- **Compte Suspendu** : L'entrepreneur ne peut plus se connecter. Son shop est automatiquement masqué.
- **Shop Masqué (Uniquement)** : L'entrepreneur peut se connecter, mais sa boutique n'est plus visible dans l'annuaire.
### 1.2. Suspension Visiteur
- L'accès à la messagerie et aux fonctionnalités interactives (avis, favoris) est bloqué.
## 2. Suppression de Compte (Soft Delete & Grace Period)
La suppression suit un cycle de vie sécurisé pour permettre la réactivation en cas d'erreur ou de changement d'avis.
### 2.1. Déclenchement par l'Admin
L'admin choisit :
1. **Le Motif** : Violation des CGU, Inactivité, Demande de l'utilisateur, etc.
2. **Le Délai** :
- **Immédiat** : Le compte est supprimé à la prochaine exécution du script de nettoyage.
- **7, 15, 30 jours** : Le compte est désactivé mais conservé en base pendant cette durée.
### 2.2. État "Suppression en cours"
- L'utilisateur ne peut plus se connecter.
- Il reçoit un email contenant un lien de réactivation.
- Dans l'admin, la ligne utilisateur est affichée en rouge avec le statut "Suppression en cours".
### 2.3. Réactivation
- L'utilisateur peut cliquer sur le lien dans son mail.
- Il doit fournir une justification pour la réactivation.
- Une fois validé, le compte redevient "Actif" et le délai de suppression est annulé.
## 3. Suppression Définitive (Hard Delete)
- S'exécute automatiquement via la tâche Cron.
- Supprime toutes les données associées en cascade (Boutique, Messages, Avis, Favoris).
- **Action irréversible**.

View File

@@ -0,0 +1,35 @@
# Guide de Maintenance et Déploiement (Admin)
## 1. Architecture Monorepo
Le projet utilise **npm workspaces**.
- Racine : Dépendances partagées, Prisma Schema, Dossier `/app` (Front).
- `/admin` : Application Next.js dédiée à l'administration.
## 2. Commandes Utiles
À exécuter depuis la racine du projet :
- `npm run dev:admin` : Lancer l'admin en développement.
- `npm run build:admin` : Builder l'admin pour la production.
- `npx prisma db push` : Synchroniser le schéma Prisma avec la base de données.
- `npx prisma generate` : Régénérer le client Prisma.
## 3. Déploiement sur Dokploy
### Configuration de l'application Admin :
- **Build Command** : `npm run build:admin`
- **Start Command** : `npm run start:admin`
- **Install Command** : `npm install --legacy-peer-deps` (à la racine)
### Variables d'environnement critiques :
- `DATABASE_URL` : Connexion PostgreSQL.
- `CRON_SECRET` : Clé pour sécuriser le nettoyage des comptes.
- `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` : Configuration pour l'envoi d'emails.
- `NEXT_PUBLIC_APP_URL` : URL du site front-end (pour les liens de réactivation).
## 4. Configuration des Tâches Cron
Pour automatiser la suppression définitive des comptes :
1. Dans Dokploy, créez une tâche Cron pour l'application Admin.
2. Planification : `0 0 * * *` (tous les jours à minuit).
3. Commande : `curl -X GET "https://votre-admin.com/api/cron/cleanup-users?key=VOTRE_SECRET"`
## 5. Gestion de la Base de Données
Le schéma Prisma se trouve à la racine : `/prisma/schema.prisma`.
Toute modification du schéma doit être suivie d'un `npx prisma db push` et d'un redémarrage des serveurs pour que le client Prisma soit mis à jour.

View File

@@ -0,0 +1,44 @@
# Spécifications Fonctionnelles - Panel Administration
## 1. Vue d'ensemble
Le panel d'administration permet de piloter l'ensemble de la plateforme Afrohub, de modérer les contenus et de gérer la configuration globale.
## 2. Tableau de Bord (Dashboard)
- Statistiques clés : Nombre d'utilisateurs, entreprises, avis en attente.
- Métriques d'audience (via le menu Metrics).
- Visualisation géographique des entreprises.
## 3. Gestion des Utilisateurs
### 3.1. Entrepreneurs
- Validation des boutiques (Certification).
- Modification des forfaits (Starter, Booster, Empire).
- Gestion de la mise en avant (Slides Home).
- Suspension de boutique ou de compte complet.
- Suppression de compte avec choix du motif et du délai (0, 7, 15, 30 jours).
### 3.2. Visiteurs
- Consultation des informations de contact.
- Vérification manuelle de l'email.
- Suspension et suppression de compte.
## 4. Modération des Contenus
- **Avis** : Approbation ou rejet des avis laissés par les utilisateurs.
- **Signalements** : Traitement des messages signalés dans la messagerie.
- **Commentaires** : Gestion des commentaires sur les articles.
## 5. Gestion éditoriale
- **Actualités** : Création, édition et suppression d'articles.
- **Interviews** : Gestion des entretiens avec les entrepreneurs.
- **Événements** : Publication et gestion de l'agenda.
- **Slides** : Configuration du carrousel de la page d'accueil (Boutiques ou Custom).
## 6. Configuration du Site
- Informations de contact (Email, Téléphone, Adresse).
- Liens réseaux sociaux.
- Gestion des documents légaux (CGU, CGV, Confidentialité).
- Personnalisation des templates d'emails (Vérification, Mot de passe, Suppression, Réactivation).
- Gestion des taxonomies (Catégories d'entreprises, Pays).
## 7. Automatisation (Cron)
- Nettoyage automatique des comptes marqués pour suppression après expiration du délai.
- URL sécurisée : `/api/cron/cleanup-users?key=SECRET`.

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