Compare commits

..

18 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
88 changed files with 8712 additions and 8655 deletions

1
admin/.npmrc Normal file
View File

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

View File

@@ -1,11 +1,23 @@
import type { NextConfig } from "next";
import path from "path";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
turbopack: {
root: path.join(__dirname, ".."),
allowedDevOrigins: ['10.0.2.2'],
// 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: '**',
},
],
},
};

7337
admin/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,33 +4,36 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"build": "prisma generate --schema=../prisma/schema.prisma && next build",
"start": "next start -p 3000",
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-pg": "6.19.3",
"@prisma/client": "6.19.3",
"@prisma/config": "6.19.3",
"@types/bcryptjs": "^2.4.6",
"afrohub": "file:..",
"bcryptjs": "^3.0.3",
"d3-scale": "^4.0.2",
"dotenv": "^17.4.1",
"lucide-react": "^1.8.0",
"next": "16.2.3",
"pg": "^8.20.0",
"prisma": "6.19.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"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": {
"@tailwindcss/postcss": "^4",
"@types/d3-scale": "^4.0.9",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-simple-maps": "^3.0.6",
"@types/topojson-client": "^3.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"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,418 +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[]
favorites Favorite[]
sentMessages Message[]
reports MessageReport[]
ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
}
model Business {
id String @id @default(uuid())
name String
category String
location String
description String
logoUrl String
videoUrl String?
contactEmail String
contactPhone String?
socialLinks Json?
verified Boolean @default(false)
viewCount Int @default(0)
rating Float @default(0.0)
ratingCount Int @default(0)
tags String[]
isFeatured Boolean @default(false)
founderName String?
founderImageUrl String?
keyMetric String?
ownerId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slug String? @unique
isActive Boolean @default(false)
showEmail Boolean @default(true)
showPhone Boolean @default(true)
showSocials Boolean @default(true)
websiteUrl String?
isSuspended Boolean @default(false)
suspensionReason String?
plan Plan @default(STARTER)
city String?
countryId String?
coverUrl String?
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
suggestedCategory String?
categoryId String?
metaDescription String?
metaTitle String?
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
country Country? @relation(fields: [countryId], references: [id])
owner User @relation(fields: [ownerId], references: [id])
comments Comment[]
conversations Conversation[]
favorites Favorite[]
homeSlides HomeSlide[]
offers Offer[]
ratings Rating[]
}
model BusinessCategory {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
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 HomeSlide {
id String @id @default(uuid())
type HomeSlideType @default(CUSTOM)
businessId String?
title String?
subtitle String?
imageUrl String?
linkUrl String?
order Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
business Business? @relation(fields: [businessId], references: [id])
}
model Interview {
id String @id @default(uuid())
title String
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 OneShotPlan {
id String @id @default(uuid())
type OneShotType @unique
name String
priceXOF Int @default(0)
priceEUR Int @default(0)
description String?
updatedAt DateTime @updatedAt
}
enum OneShotType {
BUMP
POST_EVENT
POST_NEWS
AD_DIRECTORY
}
model LegalDocument {
id String @id @default(uuid())
type String @unique
title String
content String
updatedAt DateTime @updatedAt
}
model Event {
id String @id @default(uuid())
title String
description String
date DateTime
location String
thumbnailUrl String
link String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metaDescription String?
metaTitle String?
slug String? @unique
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
}
model Favorite {
id String @id @default(uuid())
userId String
businessId String
createdAt DateTime @default(now())
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, businessId])
}
enum HomeSlideType {
BUSINESS
CUSTOM
}
enum ContentStatus {
DRAFT
PENDING
PUBLISHED
ARCHIVED
}
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
enum ReportStatus {
PENDING
RESOLVED
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;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
sources?: any;
}) {
try {
const slug = data.slug || generateSlug(data.title);
@@ -51,6 +54,9 @@ export async function updateBlogPost(id: string, data: {
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
sources?: any;
}) {
try {
const slug = data.slug || generateSlug(data.title);

View File

@@ -24,21 +24,24 @@ export async function setFeaturedBusiness(id: string, data: {
keyMetric: string;
}) {
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({
where: { isFeatured: true },
data: { isFeatured: false },
});
// 2. Set the new one
// 2. Mettre à jour l'entreprise choisie
await prisma.business.update({
where: { id },
data: {
isFeatured: true,
...data,
founderName: data.founderName || null,
founderImageUrl: data.founderImageUrl || null,
keyMetric: data.keyMetric || null,
},
});
revalidatePath("/users");
revalidatePath("/entrepreneurs");
revalidatePath("/dashboard");
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) {
try {
await prisma.business.update({
where: { id },
data: { isFeatured: false },
});
revalidatePath("/users");
revalidatePath("/entrepreneurs");
return { success: true };
} catch (error) {

View File

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

View File

@@ -29,6 +29,7 @@ export async function getSiteSettings() {
homeBlogIds: [],
homeBlogCategories: [],
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",
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>
@@ -83,3 +84,7 @@ export async function updateSiteSettings(data: any) {
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

@@ -27,6 +27,8 @@ export async function createSlide(data: any) {
linkUrl: data.linkUrl || null,
order: data.order || 0,
isActive: data.isActive ?? true,
coverPosition: data.coverPosition || "50% 50%",
coverZoom: data.coverZoom || 1,
}
});
revalidatePath("/slides");
@@ -50,6 +52,8 @@ export async function updateSlide(id: string, data: any) {
linkUrl: data.linkUrl || null,
order: data.order || 0,
isActive: data.isActive ?? true,
coverPosition: data.coverPosition || "50% 50%",
coverZoom: data.coverZoom || 1,
}
});
revalidatePath("/slides");

View File

@@ -14,7 +14,9 @@ export async function uploadImage(formData: FormData) {
const buffer = Buffer.from(bytes);
// 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}`;
// 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" };
}
}
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

@@ -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

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

View File

@@ -5,6 +5,7 @@ import DeleteActualitesButton from '@/components/DeleteActualitesButton';
import DeleteInterviewButton from '@/components/DeleteInterviewButton';
import DeleteEventButton from '@/components/DeleteEventButton';
import ApproveContentButton from '@/components/ApproveContentButton';
import { FileJson } from 'lucide-react';
async function getData() {
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
@@ -50,6 +51,7 @@ export default async function ActualitesCMSPage() {
<Plus className="w-4 h-4" />
Nouvel Événement
</Link>
</div>
</div>

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

@@ -6,19 +6,48 @@ import {
Store,
Clock,
MessageCircle,
Eye
Eye,
CheckCircle2,
UserPlus
} from 'lucide-react';
import Link from 'next/link';
interface Stats {
interface DashboardData {
stats: {
usersCount: number;
businessCount: number;
pendingCount: number;
commentsCount: 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 = [
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-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 className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
{/* Derniers entrepreneurs */}
<div className="card flex flex-col">
<div className="flex justify-between items-center mb-6">
<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="card">
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
<div 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>
{/* Activité récente */}
<div className="card flex flex-col">
<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>

View File

@@ -1,7 +1,7 @@
import { prisma } from '@/lib/prisma';
import DashboardClient from './DashboardClient';
async function getStats() {
async function getDashboardData() {
const usersCount = await prisma.user.count();
const businessCount = await prisma.business.count();
const pendingCount = await prisma.business.count({ where: { verified: false } });
@@ -21,10 +21,81 @@ async function getStats() {
});
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() {
const stats = await getStats();
return <DashboardClient stats={stats} />;
const data = await getDashboardData();
return <DashboardClient initialData={data} />;
}

View File

@@ -17,6 +17,7 @@ body {
font-family: 'Inter', sans-serif;
margin: 0;
padding: 0;
overflow-x: hidden;
}
/* Scrollbar */
@@ -42,12 +43,16 @@ body {
position: fixed;
left: 0;
top: 0;
z-index: 50;
}
.admin-content {
margin-left: 260px;
padding: 2rem;
min-height: 100vh;
width: calc(100% - 260px);
max-width: calc(100vw - 260px);
box-sizing: border-box;
}
.nav-link {
@@ -299,3 +304,23 @@ body {
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
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 (
<html lang="fr">
<body className={inter.className}>
<Toaster position="bottom-right" />
<div className="flex min-h-screen">
<Sidebar />
<main className="admin-content flex-1">
<main className="admin-content flex-1 min-w-0">
<Toaster position="top-right" />
{children}
</main>
</div>

View File

@@ -13,8 +13,12 @@ import {
Search,
ArrowUp,
ArrowDown,
Calendar
Calendar,
Map as MapIcon,
BookOpen
} from 'lucide-react';
import AnalyticsMap from '@/components/AnalyticsMap';
import CountryHistogram from '@/components/CountryHistogram';
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
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({
by: ['country'],
where: currentWhere,
where: { type: 'PAGE_VIEW', ...currentWhere },
_count: { id: true },
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({
by: ['device'],
where: currentWhere,
@@ -104,10 +124,63 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
});
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)
let prevTotalEvents = 0;
let prevUniqueVisitors = 0;
let prevMobileCount = 0;
let prevArticleViews = 0;
if (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 }
});
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
@@ -156,9 +237,53 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
totalEvents,
uniqueVisitors,
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: {
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
articleViews: prevArticleViews > 0 ? ((totalArticleViews - prevArticleViews) / prevArticleViews) * 100 : null,
mobile: prevTotalEvents > 0 && totalEvents > 0
? ((currentMobileCount / totalEvents) - (prevMobileCount / prevTotalEvents)) * 100
: 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 }> }) {
const searchParams = await searchParamsPromise;
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> = {
day: 'Dernières 24h',
@@ -230,20 +355,20 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
</div>
{/* 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="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
<Users className="w-6 h-6" />
</div>
<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} />
</div>
</div>
<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">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
Personnes réelles distinctes
</p>
</div>
@@ -253,13 +378,29 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<Eye className="w-6 h-6" />
</div>
<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} />
</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">
{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>
</div>
@@ -275,13 +416,29 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
<ComparisonBadge value={deltas.mobile} />
</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">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
Part des appareils mobiles
</p>
</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">
{/* Top Countries Table */}
<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">
<tr>
<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>
</thead>
<tbody className="divide-y divide-slate-800">
{topCountries.map((c) => (
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm text-slate-300 font-bold">{c.country || 'Inconnu'}</td>
{topCountries.map((c) => {
const uniqueCount = uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0;
return (
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors text-xs">
<td className="px-6 py-4 text-slate-300 font-bold flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-indigo-500"></span>
{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">
{c._count.id}
{uniqueCount.toLocaleString()}
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
@@ -380,6 +547,53 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
</table>
</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 */}
<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">

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
"use client";
import React, { useState, useEffect, useTransition } from 'react';
import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase } from 'lucide-react';
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase, Plus, Trash2, Key, Terminal, Copy, Check } from 'lucide-react';
import { getSiteSettings, updateSiteSettings, getExternalApiKey } from '@/app/actions/settings';
import { toast } from 'react-hot-toast';
import { getBlogPosts } from '@/app/actions/actualites';
import RichTextEditor from '@/components/RichTextEditor';
@@ -46,8 +46,12 @@ export default function SettingsPage({
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
const [notFoundQuotes, setNotFoundQuotes] = useState<string[]>([]);
const [resetPasswordTemplate, setResetPasswordTemplate] = 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
// But since the user wants it "like moderation center", I'll use URL search params
@@ -56,15 +60,21 @@ export default function SettingsPage({
useEffect(() => {
async function loadData() {
try {
const [settingsData, postsData] = await Promise.all([
const [settingsData, postsData, apiKeyData] = await Promise.all([
getSiteSettings(),
getBlogPosts()
getBlogPosts(),
getExternalApiKey()
]);
setSettings(settingsData);
setAllPosts(postsData);
setApiKey(apiKeyData);
if (typeof window !== 'undefined') {
setApiOrigin(window.location.origin);
}
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) {
@@ -81,33 +91,35 @@ export default function SettingsPage({
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = {
siteName: formData.get('siteName'),
siteSlogan: formData.get('siteSlogan'),
contactEmail: formData.get('contactEmail'),
contactPhone: formData.get('contactPhone'),
address: formData.get('address'),
facebookUrl: formData.get('facebookUrl'),
twitterUrl: formData.get('twitterUrl'),
instagramUrl: formData.get('instagramUrl'),
linkedinUrl: formData.get('linkedinUrl'),
footerText: formData.get('footerText'),
homeBlogShow: formData.get('homeBlogShow') === 'on',
homeBlogTitle: formData.get('homeBlogTitle'),
homeBlogSubtitle: formData.get('homeBlogSubtitle'),
homeBlogCount: parseInt(formData.get('homeBlogCount') as string || '3'),
homeBlogSelection: formData.get('homeBlogSelection'),
siteName: (formData.get('siteName') ?? settings?.siteName) as string,
siteSlogan: (formData.get('siteSlogan') ?? settings?.siteSlogan) as string,
contactEmail: (formData.get('contactEmail') ?? settings?.contactEmail) as string,
contactPhone: (formData.get('contactPhone') ?? settings?.contactPhone) as string,
address: (formData.get('address') ?? settings?.address) as string,
facebookUrl: (formData.get('facebookUrl') ?? settings?.facebookUrl) as string,
twitterUrl: (formData.get('twitterUrl') ?? settings?.twitterUrl) as string,
instagramUrl: (formData.get('instagramUrl') ?? settings?.instagramUrl) as string,
linkedinUrl: (formData.get('linkedinUrl') ?? settings?.linkedinUrl) as string,
footerText: (formData.get('footerText') ?? settings?.footerText) as string,
homeBlogShow: formData.has('homeBlogShow') ? formData.get('homeBlogShow') === 'on' : settings?.homeBlogShow,
homeBlogTitle: (formData.get('homeBlogTitle') ?? settings?.homeBlogTitle) as string,
homeBlogSubtitle: (formData.get('homeBlogSubtitle') ?? settings?.homeBlogSubtitle) as string,
homeBlogCount: formData.has('homeBlogCount') ? parseInt(formData.get('homeBlogCount') as string || '3') : settings?.homeBlogCount,
homeBlogSelection: (formData.get('homeBlogSelection') ?? settings?.homeBlogSelection) as string,
homeBlogIds: selectedPostIds,
homeBlogCategories: selectedBlogCategories,
homeCategories: selectedHomeCategories,
resetPasswordSubject: formData.get('resetPasswordSubject'),
notFoundQuotes: notFoundQuotes.filter(q => q.trim() !== ''),
resetPasswordSubject: (formData.get('resetPasswordSubject') ?? settings?.resetPasswordSubject) as string,
resetPasswordTemplate: resetPasswordTemplate,
verifyEmailSubject: formData.get('verifyEmailSubject'),
verifyEmailSubject: (formData.get('verifyEmailSubject') ?? settings?.verifyEmailSubject) as string,
verifyEmailTemplate: verifyEmailTemplate,
};
startTransition(async () => {
const result = await updateSiteSettings(data);
if (result.success) {
setSettings(result.settings);
toast.success("Paramètres mis à jour avec succès");
} else {
toast.error("Erreur lors de la mise à jour");
@@ -133,6 +145,22 @@ export default function SettingsPage({
);
};
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) {
return (
<div className="flex items-center justify-center p-20">
@@ -153,6 +181,7 @@ export default function SettingsPage({
{/* Tabs Switcher */}
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
<button
type="button"
onClick={() => setCurrentTab('general')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'general' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
@@ -160,12 +189,21 @@ export default function SettingsPage({
Général
</button>
<button
type="button"
onClick={() => setCurrentTab('emails')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'emails' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Mail className="w-4 h-4" />
Configuration Mail
</button>
<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>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
@@ -205,6 +243,57 @@ export default function SettingsPage({
</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 */}
<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">
@@ -511,6 +600,105 @@ export default function SettingsPage({
</div>
)}
{currentTab === 'api' && (
<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 gap-2">
<Key className="w-5 h-5 text-amber-400" />
<h2 className="font-semibold text-white">Clé d'API Externe</h2>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-slate-400">
Utilisez cette clé API pour authentifier vos requêtes lors de la création d'articles depuis un système externe.
</p>
<div className="flex items-center gap-2 max-w-xl">
<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"
@@ -525,6 +713,7 @@ export default function SettingsPage({
Enregistrer les modifications
</button>
</div>
)}
</form>
</div>
);

View File

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

View File

@@ -3,7 +3,7 @@
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 } from 'lucide-react';
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';
@@ -26,14 +26,27 @@ interface Props {
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(
@@ -41,6 +54,9 @@ export default function BlogForm({ initialData }: Props) {
? 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();
@@ -48,6 +64,48 @@ export default function BlogForm({ initialData }: Props) {
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);
@@ -65,6 +123,9 @@ export default function BlogForm({ initialData }: Props) {
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) {
@@ -100,7 +161,63 @@ export default function BlogForm({ initialData }: Props) {
</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-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>
@@ -108,7 +225,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
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"
>
<option value="DRAFT">Brouillon</option>
@@ -123,7 +240,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Titre</label>
<input
name="title"
defaultValue={initialData?.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"
@@ -133,7 +250,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Auteur</label>
<input
name="author"
defaultValue={initialData?.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"
@@ -148,6 +265,11 @@ export default function BlogForm({ initialData }: Props) {
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</div>
<div className="space-y-2">
@@ -171,7 +293,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
<textarea
name="excerpt"
defaultValue={initialData?.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"
@@ -187,6 +309,61 @@ export default function BlogForm({ initialData }: Props) {
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 */}
@@ -198,7 +375,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
<input
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"
placeholder="ex: lessor-de-la-tech-afrique"
/>
@@ -219,7 +396,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input
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"
placeholder="Titre optimisé pour les moteurs de recherche"
/>
@@ -229,7 +406,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea
name="metaDescription"
defaultValue={initialData?.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)..."

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

@@ -1,6 +1,7 @@
"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 { updateBusinessSeo } from '@/app/actions/business';
import { toast } from 'react-hot-toast';
@@ -20,8 +21,14 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
const [isPending, startTransition] = useTransition();
const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
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) => {
e.preventDefault();
@@ -39,8 +46,8 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
});
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
const modalContent = (
<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="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>
);
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 { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal';
import DeleteAccountButton from './DeleteAccountButton';
interface ClientActionsProps {
userId: string;
@@ -62,6 +63,10 @@ export default function ClientActions({ userId, userName, isSuspended }: ClientA
title="Suspendre le compte"
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

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

View File

@@ -3,7 +3,7 @@
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createEvent, updateEvent } from '@/app/actions/event';
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } from 'lucide-react';
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send, FileJson } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
@@ -19,8 +19,18 @@ interface Props {
export default function EventForm({ 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 [description, setDescription] = useState(initialData?.description || '');
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 [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
@@ -35,6 +45,46 @@ export default function EventForm({ initialData }: Props) {
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>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
@@ -53,6 +103,8 @@ export default function EventForm({ initialData }: Props) {
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
coverPosition: coverPosition,
coverZoom: coverZoom,
};
if (!data.thumbnailUrl) {
@@ -75,6 +127,17 @@ 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 (
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
@@ -88,7 +151,63 @@ export default function EventForm({ initialData }: Props) {
</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="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>
@@ -96,7 +215,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
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"
>
<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>
<input
name="title"
defaultValue={initialData?.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: Conférence AfroHub 2026"
@@ -124,7 +243,7 @@ export default function EventForm({ initialData }: Props) {
<input
name="date"
type="datetime-local"
defaultValue={initialData?.date ? new Date(initialData.date).toISOString().slice(0, 16) : ''}
defaultValue={getDefaultDateStr()}
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"
/>
@@ -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" />
<input
name="location"
defaultValue={initialData?.location}
defaultValue={currentData.location}
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"
placeholder="Ex: Abidjan, Côte-d'Ivoire / Zoom"
@@ -170,6 +289,11 @@ export default function EventForm({ initialData }: Props) {
value={thumbnailUrl}
onChange={setThumbnailUrl}
name="thumbnailUrl"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</div>
<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" />
<input
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"
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>
<input
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"
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>
<input
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"
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>
<textarea
name="metaDescription"
defaultValue={initialData?.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 courte pour les résultats de recherche..."

View File

@@ -1,6 +1,7 @@
"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 { Star, X, Loader2, Save } from 'lucide-react';
import { toast } from 'react-hot-toast';
@@ -19,6 +20,12 @@ interface Props {
export default function FeaturedModal({ business }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -54,23 +61,9 @@ export default function FeaturedModal({ business }: Props) {
}
};
return (
<>
<button
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 ${
business.isFeatured
? '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'
}`}
>
<Star className={`w-4 h-4 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
{business.isFeatured ? 'Vedette Active' : 'Mettre en avant'}
</button>
{isOpen && (
<div className="modal-overlay">
<div className="modal-content">
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"
@@ -79,39 +72,38 @@ export default function FeaturedModal({ business }: Props) {
</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>
<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</label>
<label className="text-sm font-medium text-slate-400">Nom du fondateur (Optionnel)</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>
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur (Optionnel)</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>
<label className="text-sm font-medium text-slate-400">Métrique Clé (Optionnel, ex: 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"
/>
@@ -141,7 +133,25 @@ export default function FeaturedModal({ business }: Props) {
</form>
</div>
</div>
)}
);
return (
<>
<button
onClick={() => setIsOpen(true)}
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
? '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'
}`}
title="Configurer la mise en avant Afroshine (Annuaire)"
>
<Star className={`w-3.5 h-3.5 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
{business.isFeatured ? 'Actif' : 'Activer'}
</button>
{mounted && isOpen && createPortal(modalContent, document.body)}
</>
);
}

View File

@@ -11,9 +11,25 @@ interface Props {
label?: string;
placeholder?: 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 [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -23,8 +39,9 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
if (!file) return;
// Basic validation
if (!file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image");
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/pjpeg'];
if (!allowedTypes.includes(file.type) && !file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image (JPG, PNG ou WEBP)");
return;
}
@@ -120,11 +137,17 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
</div>
</div>
) : (
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
<img
src={value}
alt="Prévisualisation"
className="w-full h-full object-cover"
style={{
objectPosition: position,
transform: `scale(${zoom})`,
transformOrigin: position
}}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
@@ -142,15 +165,83 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
<X className="w-5 h-5" />
</button>
</div>
{/* Hidden input for value to be sent via form if needed */}
{/* 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>
{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>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
accept="image/png, image/jpeg, image/jpg, image/webp"
className="hidden"
/>
</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 { useRouter } from 'next/navigation';
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 { InterviewType, ContentStatus } from '@prisma/client';
import { toast } from 'react-hot-toast';
@@ -19,6 +19,14 @@ interface Props {
export default function InterviewForm({ 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 [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
@@ -35,6 +43,50 @@ export default function InterviewForm({ initialData }: Props) {
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>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
@@ -92,7 +144,63 @@ export default function InterviewForm({ initialData }: Props) {
</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="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>
@@ -100,7 +208,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
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"
>
<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>
<input
name="title"
defaultValue={initialData?.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: 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>
<input
name="guestName"
defaultValue={initialData?.guestName}
defaultValue={currentData.guestName}
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: Sarah Koné"
@@ -135,7 +243,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Entreprise</label>
<input
name="companyName"
defaultValue={initialData?.companyName}
defaultValue={currentData.companyName}
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: TechAfrica"
@@ -145,7 +253,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
<input
name="role"
defaultValue={initialData?.role}
defaultValue={currentData.role}
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: 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>
<select
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"
>
<option value="VIDEO">Vidéo</option>
<option value="ARTICLE">Article (Texte)</option>
<option value="TEXT">Article (Texte)</option>
</select>
</div>
<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>
<input
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"
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>
<input
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"
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>
<textarea
name="excerpt"
defaultValue={initialData?.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"
@@ -241,7 +349,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
<input
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"
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>
<input
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"
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>
<textarea
name="metaDescription"
defaultValue={initialData?.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 courte SEO..."

View File

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

View File

@@ -47,7 +47,7 @@ export default function Sidebar() {
}, [pathname]); // Refresh on navigation
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="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
<ShieldCheck className="text-white" />

View File

@@ -19,6 +19,8 @@ export default function SlideForm({ initialData }: Props) {
// 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
@@ -55,6 +57,8 @@ export default function SlideForm({ initialData }: Props) {
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) {
@@ -232,6 +236,11 @@ export default function SlideForm({ initialData }: Props) {
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
showPositionControls={true}
position={coverPosition}
zoom={coverZoom}
onPositionChange={setCoverPosition}
onZoomChange={setCoverZoom}
/>
</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>
);
}

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 { PrismaPg } from '@prisma/adapter-pg'
// Updated: 2026-05-10T17:21 (Forced reload for OneShotPlan 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 pool = new pg.Pool({ connectionString })
const adapter = new PrismaPg(pool)
return new PrismaClient({ adapter })
}
const globalForPrisma = globalThis as unknown as { prisma_admin_native?: PrismaClient }
const globalForPrisma = globalThis as unknown as { prisma_v4: PrismaClient }
export const prisma = globalForPrisma.prisma_admin_native || new PrismaClient({
datasources: {
db: {
url: connectionString,
},
},
})
export const prisma = globalForPrisma.prisma_v4 || makePrisma()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v4 = prisma
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_admin_native = prisma
export default prisma

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

View File

@@ -28,7 +28,7 @@ export async function createEvent(data: {
thumbnailUrl: data.thumbnailUrl,
link: data.link || null,
tags: data.tags || [],
status: ContentStatus.PENDING, // Always pending for user submission
status: 'PENDING' as ContentStatus, // Always pending for user submission
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
}
});

View File

@@ -11,6 +11,7 @@ export async function createNews(data: {
author: string;
imageUrl: string;
tags?: string[];
sources?: any;
}) {
try {
const slug = data.title
@@ -26,12 +27,13 @@ export async function createNews(data: {
author: data.author,
imageUrl: data.imageUrl,
tags: data.tags || [],
status: ContentStatus.PENDING, // Always pending for user submission
sources: data.sources || [],
status: 'PENDING' as ContentStatus, // Always pending for user submission
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
}
});
revalidatePath("/blog");
revalidatePath("/actualites");
return { success: true, post };
} catch (error) {
console.error("Failed to create news:", error);

View File

@@ -13,7 +13,13 @@ export async function uploadImage(formData: FormData) {
// Convert to 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}`;
// Return the data URL directly (it will be saved in the database)

View File

@@ -5,6 +5,7 @@ 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 {
@@ -100,33 +101,34 @@ export default async function BlogPostPage({ params }: Props) {
}
return (
<article className="bg-gray-50 min-h-screen py-12 relative">
<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-4 sm:px-6 lg:px-8 flex gap-4 lg:gap-8 justify-center relative">
<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-[calc(100%-3rem)] sm:w-full max-w-3xl relative">
<div className="w-full max-w-3xl relative">
{/* Navigation */}
<div className="mb-8">
<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 rounded-xl shadow-sm p-6 md:p-10 border border-gray-100 relative z-10">
{/* Image d'en-tête */}
<div className="w-full h-64 md:h-80 relative mb-8 rounded-lg overflow-hidden">
<img
<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}
className="w-full h-full object-cover"
position={post.coverPosition || "50% 50%"}
zoom={post.coverZoom || 1}
/>
</div>
{/* Header de l'article */}
<header className="mb-8 border-b border-gray-100 pb-8">
<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" />
@@ -154,7 +156,7 @@ export default async function BlogPostPage({ params }: Props) {
</header>
{/* Contenu de l'article */}
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
<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}
@@ -168,8 +170,40 @@ export default async function BlogPostPage({ params }: Props) {
/>
</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-12 pt-8 border-t border-gray-100 flex flex-col sm:flex-row justify-between items-center gap-4">
<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"
@@ -183,7 +217,7 @@ export default async function BlogPostPage({ params }: Props) {
</div>
{/* Sidebar flottante verticale à droite (suit l'article et s'arrête à la fin) */}
<div className="w-10 shrink-0 relative">
<div className="hidden sm:block w-10 shrink-0 relative">
<div className="sticky top-32 z-50">
<BlogShareButtons title={post.title} />
</div>

View File

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

View File

@@ -149,7 +149,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
<img
src={item.thumbnailUrl}
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>

View File

@@ -17,7 +17,9 @@ export async function POST(request: NextRequest) {
}
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) {
return NextResponse.json(
@@ -50,11 +52,28 @@ export async function POST(request: NextRequest) {
// Check if email is verified
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(
{ error: 'Votre compte est désactivé. Veuillez le réactiver via le lien envoyé par email ou contacter le support.' },
{ status: 403 }
);
}
// Omit password from response
const { password: _, ...userWithoutPassword } = user;

View File

@@ -18,7 +18,10 @@ export async function POST(request: NextRequest) {
}
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
if (!name || !email || !password) {
@@ -47,8 +50,8 @@ export async function POST(request: NextRequest) {
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Hash password with 10 rounds to match seed and system verification standards
const hashedPassword = await bcrypt.hash(password, 10);
// Generate verification token
const verificationToken = crypto.randomBytes(32).toString('hex');
@@ -61,7 +64,7 @@ export async function POST(request: NextRequest) {
password: hashedPassword,
role: 'VISITOR', // Default role
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);
const { sendEmail } = await import('@/lib/mail');
// Send user verification email
await sendEmail({
to: email,
subject,
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) {
console.error('Failed to send verification email:', mailError);
// We don't fail registration if mail fails, but maybe we should?
// For now, just log it.
console.error('Failed to send registration email(s):', mailError);
}
// Omit password from response

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

@@ -58,3 +58,33 @@ h4 {
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 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 (
<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">
@@ -41,10 +50,11 @@ export default function NotFound() {
<div className="mt-12 pt-12 border-t border-gray-100">
<p className="text-sm text-gray-400 italic">
"L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."
"{randomQuote}"
</p>
</div>
</div>
</div>
);
}

View File

@@ -46,7 +46,7 @@ export default async function HomePage() {
// Fetch initial data for faster loading and SEO
const [featuredBusinesses, posts, categories, slides] = await Promise.all([
prisma.business.findMany({
where: { isFeatured: true, isActive: true, isSuspended: false },
where: { isHomeFeatured: true, isActive: true, isSuspended: false },
take: 4,
include: { categoryRef: true }
}),

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

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">
{featuredBusiness.name}
</h2>
{featuredBusiness.founderName && (
<p className="text-lg text-gray-600 mb-4 font-medium">
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 items-center"><Briefcase className="w-4 h-4 mr-1"/> {featuredBusiness.category}</div>

View File

@@ -6,9 +6,10 @@ import toast from 'react-hot-toast';
interface Props {
title: string;
layout?: 'vertical' | 'horizontal';
}
export default function BlogShareButtons({ title }: Props) {
export default function BlogShareButtons({ title, layout = 'vertical' }: Props) {
const [copied, setCopied] = useState(false);
const [url, setUrl] = useState('');
@@ -32,12 +33,12 @@ export default function BlogShareButtons({ title }: Props) {
if (!url) return null;
return (
<div className="flex flex-col gap-3 z-50">
<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"
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" />

View File

@@ -13,6 +13,8 @@ interface HomeSlide {
subtitle?: string | null;
imageUrl?: string | null;
linkUrl?: string | null;
coverPosition?: string | null;
coverZoom?: number | null;
}
interface Props {
@@ -85,10 +87,15 @@ const HomeSlider = ({ slides, onSearch }: Props) => {
<div className="absolute inset-0">
<img
src={slide.imageUrl || slide.business?.coverUrl || slide.business?.logoUrl || ''}
className={`w-full h-full object-cover transition-transform duration-[6000ms] ease-linear ${
index === current ? 'scale-110' : 'scale-100'
}`}
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>

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

@@ -171,10 +171,10 @@ export default function CreateEventModal({ isOpen, onClose }: CreateEventModalPr
<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 jusqu'à 2Mo</p>
<p className="text-xs text-gray-400 mt-1">PNG, JPG, JPEG jusqu'à 5Mo</p>
<input
type="file"
accept="image/*"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={handleImageChange}
className="absolute inset-0 opacity-0 cursor-pointer"
/>

View File

@@ -170,10 +170,10 @@ export default function CreateNewsModal({ isOpen, onClose }: CreateNewsModalProp
<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 jusqu'à 2Mo</p>
<p className="text-xs text-gray-400 mt-1">PNG, JPG, JPEG jusqu'à 5Mo</p>
<input
type="file"
accept="image/*"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={handleImageChange}
className="absolute inset-0 opacity-0 cursor-pointer"
/>

View File

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

View File

@@ -0,0 +1,40 @@
# Spécifications Fonctionnelles - Plateforme Front-end
## 1. Introduction
Afrohub est une plateforme dédiée à l'entrepreneuriat africain, permettant aux entrepreneurs de mettre en avant leurs activités et aux visiteurs de découvrir des entreprises, des événements et des actualités.
## 2. Rôles Utilisateurs
- **Visiteur (Non connecté)** : Peut consulter l'annuaire, les actualités, les événements et s'inscrire.
- **Client/Visiteur (Connecté)** : Peut mettre en favoris des entreprises, envoyer des messages et laisser des avis.
- **Entrepreneur** : Possède toutes les fonctionnalités du visiteur connecté, plus la gestion de sa propre boutique/entreprise (Shop).
- **Admin** : Gère la plateforme via le panel d'administration dédié.
## 3. Fonctionnalités Principales
### 3.1. Annuaire d'Entreprises
- Consultation par catégories (Technologie, Agriculture, Mode, etc.).
- Recherche par localisation et mots-clés.
- Fiches détaillées : Vidéo de présentation, coordonnées, liens sociaux, tags.
- Système de notation et avis (avec modération).
### 3.2. Actualités et Contenu
- **Articles de Blog** : Informations sur l'écosystème entrepreneurial.
- **Interviews** : Interviews d'entrepreneurs (vidéo ou article).
- **Événements** : Agenda des événements à venir.
### 3.3. Messagerie
- Mise en relation directe entre visiteurs et entrepreneurs.
- Système de conversation privée.
- Signalement de messages inappropriés.
### 3.4. Authentification et Sécurité
- Inscription et Connexion.
- Vérification d'email obligatoire.
- Réinitialisation de mot de passe.
- **Gestion du compte** : Possibilité de demander la suppression de son compte avec un délai de grâce de 30 jours.
- **Réactivation** : Possibilité de réactiver un compte en cours de suppression via un lien sécurisé.
## 4. Parcours Utilisateur
1. **Découverte** : Accueil -> Recherche -> Fiche Entreprise.
2. **Interaction** : Connexion -> Message à l'entrepreneur ou Avis.
3. **Conversion (Entrepreneur)** : Inscription -> Création de boutique -> Choix d'un forfait (Starter, Booster, Empire).

View File

@@ -10,11 +10,11 @@ function makePrisma() {
return new PrismaClient({ adapter })
}
const globalForPrisma = globalThis as unknown as { prisma_v2: PrismaClient }
const globalForPrisma = globalThis as unknown as { prisma_v3: PrismaClient }
// Updated: 2026-05-10T17:21 (Forced reload for OneShotPlan model)
export const prisma = globalForPrisma.prisma_v2 || makePrisma()
// Updated: 2026-05-12 (Forced reload for isHomeFeatured model)
export const prisma = globalForPrisma.prisma_v3 || makePrisma()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v2 = prisma
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v3 = prisma
export default prisma

View File

@@ -19,7 +19,8 @@ export async function getSiteSettings() {
homeBlogSelection: "latest",
homeBlogIds: [],
homeBlogCategories: [],
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"]
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"],
notFoundQuotes: ["L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."]
};
try {

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -9,6 +9,7 @@ const nextConfig = {
reactStrictMode: true,
//output: 'standalone',
poweredByHeader: false,
allowedDevOrigins: ['10.0.2.2'],
turbopack: {
root: __dirname,
},
@@ -28,7 +29,7 @@ const nextConfig = {
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' https:; connect-src 'self' wss: https:; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none';",
value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data:; media-src 'self' https: http:; connect-src 'self' ws: wss: https: http:; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none';",
},
{
key: 'X-Content-Type-Options',

5201
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,16 @@
"engines": {
"node": "24.x"
},
"workspaces": [
"admin"
],
"scripts": {
"dev": "next dev -p 3000",
"dev:admin": "npm run dev --prefix admin -- -p 3001",
"all": "concurrently \"npm run dev\" \"npm run dev:admin\"",
"build": "prisma generate && next build && npm run build:admin",
"build:admin": "npm install --prefix admin && prisma generate && npm run build --prefix admin",
"generate": "npx prisma generate && cd admin && npx prisma generate && cd ..",
"build": "prisma generate && next build",
"build:admin": "cd admin && npx prisma generate --schema=../prisma/schema.prisma && next build",
"generate": "npx prisma generate && cd admin && npx prisma generate --schema=../prisma/schema.prisma && cd ..",
"start": "npx prisma migrate deploy && next start",
"start:admin": "npm run start --prefix admin",
"db:migrate": "prisma migrate dev",
@@ -37,7 +40,7 @@
"dotenv": "^17.3.1",
"jiti": "^2.6.1",
"lucide-react": "^0.554.0",
"next": "^16.1.6",
"next": "16.2.3",
"nodemailer": "^8.0.6",
"pg": "^8.19.0",
"postcss": "^8.5.10",
@@ -45,6 +48,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"react-is": "^19.2.6",
"recharts": "^3.8.1",
"sanitize-html": "^2.17.3",
"tailwindcss": "^4.2.2",

View File

@@ -6,6 +6,7 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model User {
@@ -35,6 +36,10 @@ model User {
reports MessageReport[]
ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
deletedAt DateTime?
deletionScheduledAt DateTime?
deletionReason String?
reactivationReason String?
}
model Business {
@@ -54,6 +59,7 @@ model Business {
ratingCount Int @default(0)
tags String[]
isFeatured Boolean @default(false)
isHomeFeatured Boolean @default(false)
founderName String?
founderImageUrl String?
keyMetric String?
@@ -80,7 +86,7 @@ model Business {
metaTitle String?
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
country Country? @relation(fields: [countryId], references: [id])
owner User @relation(fields: [ownerId], references: [id])
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
comments Comment[]
conversations Conversation[]
favorites Favorite[]
@@ -160,6 +166,9 @@ model BlogPost {
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
sources Json? @default("[]")
}
model HomeSlide {
@@ -174,6 +183,8 @@ model HomeSlide {
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
business Business? @relation(fields: [businessId], references: [id])
}
@@ -198,6 +209,8 @@ model Interview {
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
}
model Comment {
@@ -315,6 +328,11 @@ model SiteSetting {
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>")
deleteAccountSubject String @default("Suppression de votre compte - Afrohub")
deleteAccountTemplate 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: #dc2626; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Votre demande de suppression de compte a bien été prise en compte. Votre compte sera définitivement supprimé le <strong>{deletionDate}</strong>.</p><p>Si vous changez d'avis, vous pouvez réactiver votre compte avant cette date en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{reactivateUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réactiver mon compte</a></div><p>Passé ce délai, toutes vos données seront définitivement effacées.</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>")
reactivateAccountSubject String @default("Réactivation de votre compte - Afrohub")
reactivateAccountTemplate 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>Nous avons bien reçu votre demande de réactivation de compte.</p><p><strong>Raison invoquée :</strong><br>{reason}</p><p>Votre compte est désormais actif. Vous pouvez vous connecter à nouveau.</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>")
notFoundQuotes String[] @default(["L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."])
}
model OneShotPlan {
@@ -358,6 +376,8 @@ model Event {
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
}
model Favorite {

4
scratch/debug-enum.ts Normal file
View File

@@ -0,0 +1,4 @@
import { ContentStatus } from '@prisma/client';
console.log('Available ContentStatus:', Object.values(ContentStatus));
console.log('PENDING value:', ContentStatus.PENDING);

3
scratch/debug-prisma.ts Normal file
View File

@@ -0,0 +1,3 @@
import { prisma } from './lib/prisma';
console.log('Prisma models:', Object.keys(prisma).filter(k => !k.startsWith('_') && !k.startsWith('$')));

File diff suppressed because one or more lines are too long