Compare commits
39 Commits
343f29459b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| af6ec84cd0 | |||
| ecfe7fb433 | |||
| 0054f731c7 | |||
| 8f0dc7f38c | |||
| ddb37e057d | |||
| eb43d65280 | |||
| bd6380b7aa | |||
|
|
b81216210a | ||
| 01cf4dd5a1 | |||
| 2cab2ddd1d | |||
| cfc5f1ac02 | |||
| 139d6991f5 | |||
| 1df2503f0b | |||
| ea9eaa4b8a | |||
| 3c71e483ac | |||
| 1b47498f6e | |||
| 197594f84f | |||
| 7db35361d9 | |||
| b6f828357d | |||
| 9846efd03e | |||
| 87b13dce13 | |||
| 13b1730f6f | |||
| 5176e51e21 | |||
| f4f303b703 | |||
| 7174736680 | |||
| 889d3967d2 | |||
| 94de36e937 | |||
| 15700036e5 | |||
| fe7acfa927 | |||
| c6c6a05c47 | |||
| f64b8ba53c | |||
| 3567e5d224 | |||
| 863e8fdc4a | |||
| cd862e503d | |||
|
|
4db8271ecf | ||
|
|
e6d6770c26 | ||
| 371ed17867 | |||
| bac36efb94 | |||
| 75ae9c5f3d |
2
.env
2
.env
@@ -5,4 +5,4 @@ DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?sch
|
|||||||
# Exemple avec Supabase ou Neon (si base distante)
|
# Exemple avec Supabase ou Neon (si base distante)
|
||||||
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
||||||
# Environnement : "development" = accents rouges, "production" = accents normaux
|
# Environnement : "development" = accents rouges, "production" = accents normaux
|
||||||
NEXT_PUBLIC_APP_ENV=development
|
NEXT_PUBLIC_APP_ENV=development
|
||||||
|
|||||||
1
admin/.npmrc
Normal file
1
admin/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
turbopack: {
|
allowedDevOrigins: ['10.0.2.2'],
|
||||||
root: path.join(__dirname, ".."),
|
|
||||||
|
// 1. On ignore les erreurs de type et ESLint pour que le build
|
||||||
|
// de production ne plante pas à cause des imports Prisma
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. (Optionnel) Pour faciliter le chargement des images si tu en as
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '**',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
7337
admin/package-lock.json
generated
7337
admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,33 +4,36 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "prisma generate && next build",
|
"build": "prisma generate --schema=../prisma/schema.prisma && next build",
|
||||||
"start": "next start -p 3000",
|
"start": "next start -p 3000",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "6.19.3",
|
|
||||||
"@prisma/client": "6.19.3",
|
|
||||||
"@prisma/config": "6.19.3",
|
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"afrohub": "file:..",
|
"afrohub": "file:..",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
"dotenv": "^17.4.1",
|
"dotenv": "^17.4.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"prisma": "6.19.3",
|
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-quill-new": "^3.8.3"
|
"react-quill-new": "^3.8.3",
|
||||||
|
"react-simple-maps": "^3.0.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"topojson-client": "^3.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/react-simple-maps": "^3.0.6",
|
||||||
|
"@types/topojson-client": "^3.1.5",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
Binary file not shown.
@@ -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;">© 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;">© 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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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';
|
|
||||||
@@ -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"
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
binaryTargets = ["native", "linux-musl", "linux-musl-openssl-3.0.x"]
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
name String
|
|
||||||
email String @unique
|
|
||||||
password String
|
|
||||||
role UserRole @default(VISITOR)
|
|
||||||
avatar String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
bio String?
|
|
||||||
location String?
|
|
||||||
phone String?
|
|
||||||
isSuspended Boolean @default(false)
|
|
||||||
suspensionReason String?
|
|
||||||
countryId String?
|
|
||||||
resetToken String? @unique
|
|
||||||
resetTokenExpiry DateTime?
|
|
||||||
emailVerified Boolean @default(false)
|
|
||||||
verificationToken String? @unique
|
|
||||||
businesses Business?
|
|
||||||
comments Comment[]
|
|
||||||
conversations ConversationParticipant[]
|
|
||||||
sentMessages Message[]
|
|
||||||
reports MessageReport[]
|
|
||||||
ratings Rating[]
|
|
||||||
country Country? @relation(fields: [countryId], references: [id])
|
|
||||||
favorites Favorite[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Business {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
name String
|
|
||||||
category String
|
|
||||||
location String
|
|
||||||
description String
|
|
||||||
logoUrl String
|
|
||||||
videoUrl String?
|
|
||||||
contactEmail String
|
|
||||||
contactPhone String?
|
|
||||||
socialLinks Json?
|
|
||||||
verified Boolean @default(false)
|
|
||||||
viewCount Int @default(0)
|
|
||||||
rating Float @default(0.0)
|
|
||||||
ratingCount Int @default(0)
|
|
||||||
tags String[]
|
|
||||||
isFeatured Boolean @default(false)
|
|
||||||
founderName String?
|
|
||||||
founderImageUrl String?
|
|
||||||
keyMetric String?
|
|
||||||
ownerId String @unique
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
slug String? @unique
|
|
||||||
isActive Boolean @default(false)
|
|
||||||
showEmail Boolean @default(true)
|
|
||||||
showPhone Boolean @default(true)
|
|
||||||
showSocials Boolean @default(true)
|
|
||||||
websiteUrl String?
|
|
||||||
isSuspended Boolean @default(false)
|
|
||||||
suspensionReason String?
|
|
||||||
metaTitle String?
|
|
||||||
metaDescription String?
|
|
||||||
plan Plan @default(STARTER)
|
|
||||||
city String?
|
|
||||||
countryId String?
|
|
||||||
coverUrl String?
|
|
||||||
coverPosition String? @default("50% 50%")
|
|
||||||
coverZoom Float? @default(1.0)
|
|
||||||
suggestedCategory String?
|
|
||||||
categoryId String?
|
|
||||||
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
|
|
||||||
country Country? @relation(fields: [countryId], references: [id])
|
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
|
||||||
comments Comment[]
|
|
||||||
conversations Conversation[]
|
|
||||||
offers Offer[]
|
|
||||||
ratings Rating[]
|
|
||||||
favorites Favorite[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model BusinessCategory {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
name String @unique
|
|
||||||
slug String @unique
|
|
||||||
icon String?
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
businesses Business[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Country {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
name String @unique
|
|
||||||
code String @unique
|
|
||||||
flag String?
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
description String?
|
|
||||||
businesses Business[]
|
|
||||||
users User[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model PricingPlan {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
tier Plan @unique
|
|
||||||
name String
|
|
||||||
priceXOF String
|
|
||||||
priceEUR String
|
|
||||||
description String
|
|
||||||
features String[]
|
|
||||||
offerLimit Int @default(1)
|
|
||||||
recommended Boolean @default(false)
|
|
||||||
color String @default("gray")
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
yearlyPriceEUR String?
|
|
||||||
yearlyPriceXOF String?
|
|
||||||
}
|
|
||||||
|
|
||||||
model Offer {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
title String
|
|
||||||
type OfferType
|
|
||||||
price Float
|
|
||||||
currency String @default("XOF")
|
|
||||||
description String?
|
|
||||||
imageUrl String
|
|
||||||
active Boolean @default(true)
|
|
||||||
businessId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model BlogPost {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
title String
|
|
||||||
excerpt String
|
|
||||||
content String
|
|
||||||
author String
|
|
||||||
date DateTime @default(now())
|
|
||||||
imageUrl String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
metaDescription String?
|
|
||||||
metaTitle String?
|
|
||||||
slug String? @unique
|
|
||||||
tags String[] @default([])
|
|
||||||
publishedAt DateTime @default(now())
|
|
||||||
status ContentStatus @default(PUBLISHED)
|
|
||||||
}
|
|
||||||
|
|
||||||
model Interview {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
title String
|
|
||||||
guestName String
|
|
||||||
companyName String
|
|
||||||
role String
|
|
||||||
type InterviewType
|
|
||||||
thumbnailUrl String
|
|
||||||
videoUrl String?
|
|
||||||
content String?
|
|
||||||
excerpt String
|
|
||||||
date DateTime @default(now())
|
|
||||||
duration String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
metaDescription String?
|
|
||||||
metaTitle String?
|
|
||||||
slug String? @unique
|
|
||||||
tags String[] @default([])
|
|
||||||
publishedAt DateTime @default(now())
|
|
||||||
status ContentStatus @default(PUBLISHED)
|
|
||||||
}
|
|
||||||
|
|
||||||
model Comment {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
content String
|
|
||||||
authorId String
|
|
||||||
businessId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model AnalyticsEvent {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
type String
|
|
||||||
path String
|
|
||||||
label String?
|
|
||||||
value Float?
|
|
||||||
metadata Json?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
browser String?
|
|
||||||
city String?
|
|
||||||
country String?
|
|
||||||
device String?
|
|
||||||
ip String?
|
|
||||||
os String?
|
|
||||||
referrer String?
|
|
||||||
userId String?
|
|
||||||
}
|
|
||||||
|
|
||||||
model Rating {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
value Int
|
|
||||||
userId String
|
|
||||||
businessId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
comment String?
|
|
||||||
reply String?
|
|
||||||
replyAt DateTime?
|
|
||||||
status RatingStatus @default(PENDING)
|
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([userId, businessId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Conversation {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
businessId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
||||||
participants ConversationParticipant[]
|
|
||||||
messages Message[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model ConversationParticipant {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
conversationId String
|
|
||||||
isArchived Boolean @default(false)
|
|
||||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([userId, conversationId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Message {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
content String
|
|
||||||
senderId String
|
|
||||||
conversationId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
read Boolean @default(false)
|
|
||||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
|
||||||
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
|
||||||
reports MessageReport[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model MessageReport {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
reason String?
|
|
||||||
messageId String
|
|
||||||
reporterId String
|
|
||||||
status ReportStatus @default(PENDING)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
|
||||||
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model SiteSetting {
|
|
||||||
id String @id @default("singleton")
|
|
||||||
siteName String @default("Afrohub")
|
|
||||||
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
|
|
||||||
contactEmail String @default("support@afrohub.com")
|
|
||||||
contactPhone String? @default("+225 00 00 00 00 00")
|
|
||||||
address String? @default("Abidjan, Côte d'Ivoire")
|
|
||||||
facebookUrl String?
|
|
||||||
twitterUrl String?
|
|
||||||
instagramUrl String?
|
|
||||||
linkedinUrl String?
|
|
||||||
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
homeBlogCount Int @default(3)
|
|
||||||
homeBlogShow Boolean @default(true)
|
|
||||||
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
|
|
||||||
homeBlogTitle String @default("Derniers Articles")
|
|
||||||
homeBlogCategories String[] @default([])
|
|
||||||
homeBlogIds String[] @default([])
|
|
||||||
homeBlogSelection String @default("latest")
|
|
||||||
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
|
|
||||||
resetPasswordSubject String @default("Réinitialisation de votre mot de passe - Afrohub")
|
|
||||||
resetPasswordTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{resetUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 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;\">© 2025 {siteName}. Tous droits réservés.</p></div>")
|
|
||||||
}
|
|
||||||
|
|
||||||
model LegalDocument {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
type String @unique
|
|
||||||
title String
|
|
||||||
content String
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Event {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
title String
|
|
||||||
description String
|
|
||||||
date DateTime
|
|
||||||
location String
|
|
||||||
thumbnailUrl String
|
|
||||||
link String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
metaDescription String?
|
|
||||||
metaTitle String?
|
|
||||||
slug String? @unique
|
|
||||||
tags String[] @default([])
|
|
||||||
publishedAt DateTime @default(now())
|
|
||||||
status ContentStatus @default(PUBLISHED)
|
|
||||||
}
|
|
||||||
|
|
||||||
model Favorite {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
businessId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([userId, businessId])
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ContentStatus {
|
|
||||||
DRAFT
|
|
||||||
PUBLISHED
|
|
||||||
ARCHIVED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RatingStatus {
|
|
||||||
PENDING
|
|
||||||
APPROVED
|
|
||||||
REJECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ReportStatus {
|
|
||||||
PENDING
|
|
||||||
RESOLVED
|
|
||||||
DISMISSED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum UserRole {
|
|
||||||
VISITOR
|
|
||||||
ENTREPRENEUR
|
|
||||||
ADMIN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Plan {
|
|
||||||
STARTER
|
|
||||||
BOOSTER
|
|
||||||
EMPIRE
|
|
||||||
}
|
|
||||||
|
|
||||||
enum OfferType {
|
|
||||||
PRODUCT
|
|
||||||
SERVICE
|
|
||||||
}
|
|
||||||
|
|
||||||
enum InterviewType {
|
|
||||||
VIDEO
|
|
||||||
ARTICLE
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,9 @@ export async function createBlogPost(data: {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
publishedAt?: Date;
|
publishedAt?: Date;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
|
coverPosition?: string;
|
||||||
|
coverZoom?: number;
|
||||||
|
sources?: any;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -30,7 +33,8 @@ export async function createBlogPost(data: {
|
|||||||
status: data.status || ContentStatus.PUBLISHED,
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/actualites");
|
||||||
|
revalidatePath("/");
|
||||||
return { success: true, id: post.id };
|
return { success: true, id: post.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create blog post:", error);
|
console.error("Failed to create blog post:", error);
|
||||||
@@ -50,6 +54,9 @@ export async function updateBlogPost(id: string, data: {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
publishedAt?: Date;
|
publishedAt?: Date;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
|
coverPosition?: string;
|
||||||
|
coverZoom?: number;
|
||||||
|
sources?: any;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -62,7 +69,8 @@ export async function updateBlogPost(id: string, data: {
|
|||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/actualites");
|
||||||
|
revalidatePath("/");
|
||||||
return { success: true, id: post.id };
|
return { success: true, id: post.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update blog post:", error);
|
console.error("Failed to update blog post:", error);
|
||||||
@@ -75,7 +83,8 @@ export async function deleteBlogPost(id: string) {
|
|||||||
await prisma.blogPost.delete({
|
await prisma.blogPost.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/actualites");
|
||||||
|
revalidatePath("/");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete blog post:", error);
|
console.error("Failed to delete blog post:", error);
|
||||||
64
admin/src/app/actions/approval.ts
Normal file
64
admin/src/app/actions/approval.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function approveBlogPost(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.blogPost.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: ContentStatus.PUBLISHED }
|
||||||
|
});
|
||||||
|
revalidatePath("/actualites");
|
||||||
|
revalidatePath("/blog");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to approve blog post:", error);
|
||||||
|
return { success: false, error: "Erreur lors de l'approbation" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveEvent(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.event.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: ContentStatus.PUBLISHED }
|
||||||
|
});
|
||||||
|
revalidatePath("/actualites");
|
||||||
|
revalidatePath("/evenements");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to approve event:", error);
|
||||||
|
return { success: false, error: "Erreur lors de l'approbation" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectBlogPost(id: string) {
|
||||||
|
try {
|
||||||
|
// We could delete or archive it. Let's archive it.
|
||||||
|
await prisma.blogPost.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: ContentStatus.ARCHIVED }
|
||||||
|
});
|
||||||
|
revalidatePath("/actualites");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reject blog post:", error);
|
||||||
|
return { success: false, error: "Erreur lors du rejet" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectEvent(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.event.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: ContentStatus.ARCHIVED }
|
||||||
|
});
|
||||||
|
revalidatePath("/actualites");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reject event:", error);
|
||||||
|
return { success: false, error: "Erreur lors du rejet" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,21 +24,24 @@ export async function setFeaturedBusiness(id: string, data: {
|
|||||||
keyMetric: string;
|
keyMetric: string;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
// 1. Reset all others (we only want one entrepreneur of the month)
|
// 1. Réinitialiser les autres pour n'avoir qu'une seule Tête d'affiche Afroshine active
|
||||||
await prisma.business.updateMany({
|
await prisma.business.updateMany({
|
||||||
where: { isFeatured: true },
|
where: { isFeatured: true },
|
||||||
data: { isFeatured: false },
|
data: { isFeatured: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Set the new one
|
// 2. Mettre à jour l'entreprise choisie
|
||||||
await prisma.business.update({
|
await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
...data,
|
founderName: data.founderName || null,
|
||||||
|
founderImageUrl: data.founderImageUrl || null,
|
||||||
|
keyMetric: data.keyMetric || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
revalidatePath("/users");
|
||||||
revalidatePath("/entrepreneurs");
|
revalidatePath("/entrepreneurs");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -48,12 +51,28 @@ export async function setFeaturedBusiness(id: string, data: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleHomeFeatured(id: string, currentStatus: boolean) {
|
||||||
|
try {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isHomeFeatured: !currentStatus },
|
||||||
|
});
|
||||||
|
revalidatePath("/users");
|
||||||
|
revalidatePath("/");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle home featured:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour de la mise à la une" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeFeaturedBusiness(id: string) {
|
export async function removeFeaturedBusiness(id: string) {
|
||||||
try {
|
try {
|
||||||
await prisma.business.update({
|
await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isFeatured: false },
|
data: { isFeatured: false },
|
||||||
});
|
});
|
||||||
|
revalidatePath("/users");
|
||||||
revalidatePath("/entrepreneurs");
|
revalidatePath("/entrepreneurs");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export async function createEvent(data: {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
publishedAt?: Date;
|
publishedAt?: Date;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
|
coverPosition?: string;
|
||||||
|
coverZoom?: number;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -53,6 +55,8 @@ export async function updateEvent(id: string, data: {
|
|||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
publishedAt?: Date;
|
publishedAt?: Date;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
|
coverPosition?: string;
|
||||||
|
coverZoom?: number;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|||||||
40
admin/src/app/actions/one-shot.ts
Normal file
40
admin/src/app/actions/one-shot.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { OneShotType } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function getOneShotPlans() {
|
||||||
|
try {
|
||||||
|
return await prisma.oneShotPlan.findMany({
|
||||||
|
orderBy: { type: 'asc' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch one-shot plans:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOneShotPlan(type: OneShotType, data: {
|
||||||
|
name: string;
|
||||||
|
priceXOF: number;
|
||||||
|
priceEUR: number;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await prisma.oneShotPlan.upsert({
|
||||||
|
where: { type },
|
||||||
|
update: data,
|
||||||
|
create: {
|
||||||
|
type,
|
||||||
|
...data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
revalidatePath("/settings");
|
||||||
|
revalidatePath("/subscription");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update one-shot plan:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export async function getSiteSettings() {
|
|||||||
homeBlogIds: [],
|
homeBlogIds: [],
|
||||||
homeBlogCategories: [],
|
homeBlogCategories: [],
|
||||||
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"],
|
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"],
|
||||||
|
notFoundQuotes: ["L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."],
|
||||||
resetPasswordSubject: "Réinitialisation de votre mot de passe - Afrohub",
|
resetPasswordSubject: "Réinitialisation de votre mot de passe - Afrohub",
|
||||||
resetPasswordTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
resetPasswordTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
|
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
|
||||||
@@ -83,3 +84,7 @@ export async function updateSiteSettings(data: any) {
|
|||||||
return { success: false, error: "Erreur lors de la mise à jour des paramètres" };
|
return { success: false, error: "Erreur lors de la mise à jour des paramètres" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getExternalApiKey() {
|
||||||
|
return process.env.EXTERNAL_API_KEY || "ezfzeFZEfztZEgZEFzETGZEGZERZERF";
|
||||||
|
}
|
||||||
|
|||||||
118
admin/src/app/actions/slides.ts
Normal file
118
admin/src/app/actions/slides.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function getSlides() {
|
||||||
|
try {
|
||||||
|
return await prisma.homeSlide.findMany({
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: { business: true }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get slides:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSlide(data: any) {
|
||||||
|
try {
|
||||||
|
await prisma.homeSlide.create({
|
||||||
|
data: {
|
||||||
|
type: data.type,
|
||||||
|
businessId: data.businessId || null,
|
||||||
|
title: data.title || null,
|
||||||
|
subtitle: data.subtitle || null,
|
||||||
|
imageUrl: data.imageUrl || null,
|
||||||
|
linkUrl: data.linkUrl || null,
|
||||||
|
order: data.order || 0,
|
||||||
|
isActive: data.isActive ?? true,
|
||||||
|
coverPosition: data.coverPosition || "50% 50%",
|
||||||
|
coverZoom: data.coverZoom || 1,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
revalidatePath("/slides");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create slide:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSlide(id: string, data: any) {
|
||||||
|
try {
|
||||||
|
await prisma.homeSlide.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
type: data.type,
|
||||||
|
businessId: data.businessId || null,
|
||||||
|
title: data.title || null,
|
||||||
|
subtitle: data.subtitle || null,
|
||||||
|
imageUrl: data.imageUrl || null,
|
||||||
|
linkUrl: data.linkUrl || null,
|
||||||
|
order: data.order || 0,
|
||||||
|
isActive: data.isActive ?? true,
|
||||||
|
coverPosition: data.coverPosition || "50% 50%",
|
||||||
|
coverZoom: data.coverZoom || 1,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
revalidatePath("/slides");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update slide:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSlide(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.homeSlide.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
revalidatePath("/slides");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete slide:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la suppression" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleSlideStatus(id: string, currentStatus: boolean) {
|
||||||
|
try {
|
||||||
|
await prisma.homeSlide.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: !currentStatus }
|
||||||
|
});
|
||||||
|
revalidatePath("/slides");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle slide status:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchBusinesses(query: string) {
|
||||||
|
try {
|
||||||
|
return await prisma.business.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ location: { contains: query, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
logoUrl: true,
|
||||||
|
coverUrl: true,
|
||||||
|
description: true,
|
||||||
|
location: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to search businesses:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ export async function uploadImage(formData: FormData) {
|
|||||||
const buffer = Buffer.from(bytes);
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
// Create a unique filename
|
// Create a unique filename
|
||||||
const fileExtension = file.name.split('.').pop();
|
let fileExtension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
|
||||||
|
if (fileExtension === 'jpeg') fileExtension = 'jpg';
|
||||||
|
|
||||||
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
|
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
|
||||||
|
|
||||||
// Path for the ROOT public/uploads (where the main site will look)
|
// Path for the ROOT public/uploads (where the main site will look)
|
||||||
|
|||||||
@@ -41,3 +41,95 @@ export async function verifyUserEmail(userId: string) {
|
|||||||
return { success: false, error: "Erreur lors de la vérification de l'email" };
|
return { success: false, error: "Erreur lors de la vérification de l'email" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export async function scheduleUserDeletion(userId: string, reason: string, delayDays: number) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return { success: false, error: "Utilisateur non trouvé" };
|
||||||
|
|
||||||
|
const settings = await prisma.siteSetting.findUnique({
|
||||||
|
where: { id: "singleton" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletionDate = new Date();
|
||||||
|
deletionDate.setDate(deletionDate.getDate() + delayDays);
|
||||||
|
|
||||||
|
if (delayDays === 0) {
|
||||||
|
// Immediate deletion
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
return { success: true, message: "Supprimé immédiatement" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
deletedAt: new Date(),
|
||||||
|
deletionScheduledAt: deletionDate,
|
||||||
|
deletionReason: reason
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send notification email
|
||||||
|
if (settings?.deleteAccountTemplate) {
|
||||||
|
const { sendEmail } = await import("@/lib/mail");
|
||||||
|
const html = settings.deleteAccountTemplate
|
||||||
|
.replace(/{name}/g, user.name)
|
||||||
|
.replace(/{siteName}/g, settings.siteName)
|
||||||
|
.replace(/{deletionDate}/g, delayDays === 0 ? "immédiatement" : deletionDate.toLocaleDateString("fr-FR"))
|
||||||
|
.replace(/{reason}/g, reason)
|
||||||
|
.replace(/{reactivateUrl}/g, `${process.env.NEXT_PUBLIC_APP_URL}/auth/reactivate?id=${user.id}`);
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: settings.deleteAccountSubject,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to schedule user deletion:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la programmation de la suppression" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reactivateUser(userId: string, reason: string) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
deletedAt: null,
|
||||||
|
deletionScheduledAt: null,
|
||||||
|
reactivationReason: reason
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await prisma.siteSetting.findUnique({
|
||||||
|
where: { id: "singleton" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send confirmation email
|
||||||
|
if (settings?.reactivateAccountTemplate) {
|
||||||
|
const { sendEmail } = await import("@/lib/mail");
|
||||||
|
const html = settings.reactivateAccountTemplate
|
||||||
|
.replace(/{name}/g, user.name)
|
||||||
|
.replace(/{siteName}/g, settings.siteName)
|
||||||
|
.replace(/{reason}/g, reason);
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: settings.reactivateAccountSubject,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reactivate user:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la réactivation de l'utilisateur" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import BlogForm from "@/components/BlogForm";
|
import ActualitesForm from "@/components/ActualitesForm";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
export default async function EditBlogPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function EditActualitesPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const post = await prisma.blogPost.findUnique({
|
const post = await prisma.blogPost.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -12,5 +12,5 @@ export default async function EditBlogPage({ params }: { params: Promise<{ id: s
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <BlogForm initialData={post} />;
|
return <ActualitesForm initialData={post} />;
|
||||||
}
|
}
|
||||||
13
admin/src/app/actualites/import/page.tsx
Normal file
13
admin/src/app/actualites/import/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
admin/src/app/actualites/new/page.tsx
Normal file
7
admin/src/app/actualites/new/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ActualitesForm from "@/components/ActualitesForm";
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function NewActualitesPage() {
|
||||||
|
return <ActualitesForm />;
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin } from 'lucide-react';
|
import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin, Clock, ShieldCheck, ShieldAlert } from 'lucide-react';
|
||||||
import DeleteBlogButton from '@/components/DeleteBlogButton';
|
import DeleteActualitesButton from '@/components/DeleteActualitesButton';
|
||||||
import DeleteInterviewButton from '@/components/DeleteInterviewButton';
|
import DeleteInterviewButton from '@/components/DeleteInterviewButton';
|
||||||
import DeleteEventButton from '@/components/DeleteEventButton';
|
import DeleteEventButton from '@/components/DeleteEventButton';
|
||||||
|
import ApproveContentButton from '@/components/ApproveContentButton';
|
||||||
|
import { FileJson } from 'lucide-react';
|
||||||
|
|
||||||
async function getData() {
|
async function getData() {
|
||||||
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
|
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
|
||||||
@@ -14,25 +16,30 @@ async function getData() {
|
|||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
case 'PENDING': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-500 border border-amber-500/20 flex items-center gap-1 w-fit"><Clock className="w-3 h-3" /> EN ATTENTE</span>;
|
||||||
case 'DRAFT': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-500/10 text-slate-400 border border-slate-500/20">BROUILLON</span>;
|
case 'DRAFT': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-500/10 text-slate-400 border border-slate-500/20">BROUILLON</span>;
|
||||||
case 'PUBLISHED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">PUBLIÉ</span>;
|
case 'PUBLISHED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">PUBLIÉ</span>;
|
||||||
case 'ARCHIVED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20">ARCHIVÉ</span>;
|
case 'ARCHIVED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-red-500/10 text-red-400 border border-red-500/20 text-xs tracking-tighter">REJETÉ/ARCHIVÉ</span>;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogCMSPage() {
|
export default async function ActualitesCMSPage() {
|
||||||
const { posts, interviews, events } = await getData();
|
const { posts, interviews, events } = await getData();
|
||||||
|
|
||||||
|
const pendingPosts = posts.filter(p => p.status === 'PENDING');
|
||||||
|
const pendingEvents = events.filter(e => e.status === 'PENDING');
|
||||||
|
const hasPending = pendingPosts.length > 0 || pendingEvents.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex justify-between items-end mb-8">
|
<div className="flex justify-between items-end mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">CMS Blog, Interviews & Événements</h1>
|
<h1 className="text-3xl font-bold text-white mb-2">CMS Actualités, Interviews & Événements</h1>
|
||||||
<p className="text-slate-400">Gérez le contenu éditorial et événementiel de la plateforme.</p>
|
<p className="text-slate-400">Gérez le contenu éditorial et événementiel de la plateforme.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<Link href="/blog/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
|
<Link href="/actualites/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Nouvel Article
|
Nouvel Article
|
||||||
</Link>
|
</Link>
|
||||||
@@ -44,15 +51,81 @@ export default async function BlogCMSPage() {
|
|||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Nouvel Événement
|
Nouvel Événement
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* PENDING SUBMISSIONS SECTION */}
|
||||||
|
{hasPending && (
|
||||||
|
<div className="bg-amber-500/5 border border-amber-500/20 rounded-2xl p-6 mb-8 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="p-2 bg-amber-500/20 rounded-xl text-amber-500">
|
||||||
|
<ShieldAlert className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">Soumissions en attente ({pendingPosts.length + pendingEvents.length})</h2>
|
||||||
|
<p className="text-sm text-slate-400">Contenu soumis par les utilisateurs nécessitant une validation.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{pendingPosts.map(post => (
|
||||||
|
<div key={post.id} className="bg-slate-900/50 border border-slate-800 rounded-xl p-4 flex items-center justify-between group hover:border-amber-500/30 transition-all">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-indigo-500/10 rounded-lg flex items-center justify-center text-indigo-400">
|
||||||
|
<BookOpen className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-bold text-white">{post.title}</h4>
|
||||||
|
<span className="text-[10px] bg-indigo-500/10 text-indigo-400 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter border border-indigo-500/20">Article</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">Par {post.author} • {new Date(post.createdAt).toLocaleDateString('fr-FR')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Link href={`/actualites/edit/${post.id}`} className="p-2 text-slate-400 hover:text-white" title="Modifier/Voir">
|
||||||
|
<Edit className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<ApproveContentButton id={post.id} type="post" action="approve" />
|
||||||
|
<ApproveContentButton id={post.id} type="post" action="reject" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pendingEvents.map(event => (
|
||||||
|
<div key={event.id} className="bg-slate-900/50 border border-slate-800 rounded-xl p-4 flex items-center justify-between group hover:border-amber-500/30 transition-all">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-amber-500/10 rounded-lg flex items-center justify-center text-amber-500">
|
||||||
|
<Calendar className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-bold text-white">{event.title}</h4>
|
||||||
|
<span className="text-[10px] bg-amber-500/10 text-amber-400 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter border border-amber-500/20">Événement</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{new Date(event.date).toLocaleDateString('fr-FR')} • {event.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Link href={`/event/edit/${event.id}`} className="p-2 text-slate-400 hover:text-white" title="Modifier/Voir">
|
||||||
|
<Edit className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<ApproveContentButton id={event.id} type="event" action="approve" />
|
||||||
|
<ApproveContentButton id={event.id} type="event" action="reject" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
{/* Blog Posts Section */}
|
{/* Blog Posts Section */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<BookOpen className="text-indigo-400 w-5 h-5" />
|
<BookOpen className="text-indigo-400 w-5 h-5" />
|
||||||
<h2 className="text-xl font-bold text-white">Articles de Blog ({posts.length})</h2>
|
<h2 className="text-xl font-bold text-white">Articles d'Actualités ({posts.length})</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="data-table w-full">
|
<table className="data-table w-full">
|
||||||
@@ -77,10 +150,10 @@ export default async function BlogCMSPage() {
|
|||||||
<td className="py-4 px-4 text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
|
<td className="py-4 px-4 text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
|
||||||
<td className="py-4 px-4 text-right">
|
<td className="py-4 px-4 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Link href={`/blog/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
|
<Link href={`/actualites/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
|
||||||
<Edit className="w-5 h-5" />
|
<Edit className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<DeleteBlogButton id={post.id} />
|
<DeleteActualitesButton id={post.id} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
51
admin/src/app/api/cron/cleanup-users/route.ts
Normal file
51
admin/src/app/api/cron/cleanup-users/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
64
admin/src/app/api/external/articles/route.ts
vendored
Normal file
64
admin/src/app/api/external/articles/route.ts
vendored
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
58
admin/src/app/api/external/events/route.ts
vendored
Normal file
58
admin/src/app/api/external/events/route.ts
vendored
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import BlogForm from "@/components/BlogForm";
|
|
||||||
|
|
||||||
export default function NewBlogPage() {
|
|
||||||
return <BlogForm />;
|
|
||||||
}
|
|
||||||
@@ -6,19 +6,48 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Clock,
|
Clock,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Eye
|
Eye,
|
||||||
|
CheckCircle2,
|
||||||
|
UserPlus
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface Stats {
|
interface DashboardData {
|
||||||
usersCount: number;
|
stats: {
|
||||||
businessCount: number;
|
usersCount: number;
|
||||||
pendingCount: number;
|
businessCount: number;
|
||||||
commentsCount: number;
|
pendingCount: number;
|
||||||
totalViews: number;
|
commentsCount: number;
|
||||||
uniqueVisitors: number;
|
totalViews: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
};
|
||||||
|
latestBusinesses: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
logoUrl: string;
|
||||||
|
location: string;
|
||||||
|
createdAt: Date;
|
||||||
|
verified: boolean;
|
||||||
|
plan: string;
|
||||||
|
owner?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
activities: Array<{
|
||||||
|
id: string;
|
||||||
|
type: 'comment' | 'user';
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
timestamp: Date;
|
||||||
|
avatar?: string | null;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardClient({ stats }: { stats: Stats }) {
|
export default function DashboardClient({ initialData }: { initialData: DashboardData }) {
|
||||||
|
const { stats, latestBusinesses, activities } = initialData;
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
||||||
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
|
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
|
||||||
@@ -50,16 +79,86 @@ export default function DashboardClient({ stats }: { stats: Stats }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="card">
|
{/* Derniers entrepreneurs */}
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
|
<div className="card flex flex-col">
|
||||||
<div className="space-y-4">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
<h2 className="text-xl font-bold text-white">Derniers entrepreneurs</h2>
|
||||||
|
<Link href="/users" className="text-xs text-indigo-400 hover:underline font-semibold">
|
||||||
|
Voir tout
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
|
{latestBusinesses.length === 0 ? (
|
||||||
|
<p className="text-slate-500 italic py-8 text-center">Aucune entreprise récente.</p>
|
||||||
|
) : (
|
||||||
|
latestBusinesses.map((b) => (
|
||||||
|
<div key={b.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-800/40 hover:bg-slate-800/80 transition-colors border border-slate-800">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-slate-800 overflow-hidden flex items-center justify-center border border-slate-700 flex-shrink-0">
|
||||||
|
{b.logoUrl ? (
|
||||||
|
<img src={b.logoUrl} alt={b.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Store className="w-4 h-4 text-slate-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-white text-sm truncate">{b.name}</div>
|
||||||
|
<div className="text-xs text-slate-400 truncate">{b.category} • {b.location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||||
|
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase ${
|
||||||
|
b.plan === 'EMPIRE' ? 'bg-purple-500/10 text-purple-400 border border-purple-500/20' :
|
||||||
|
b.plan === 'BOOSTER' ? 'bg-amber-500/10 text-amber-400 border border-amber-500/20' :
|
||||||
|
'bg-slate-500/10 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{b.plan}
|
||||||
|
</span>
|
||||||
|
{b.verified ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-500" title="Vérifié" />
|
||||||
|
) : (
|
||||||
|
<Clock className="w-4 h-4 text-amber-500" title="En attente" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
|
{/* Activité récente */}
|
||||||
<div className="space-y-4">
|
<div className="card flex flex-col">
|
||||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
<h2 className="text-xl font-bold text-white mb-6">Activité récente</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<p className="text-slate-500 italic py-8 text-center">Aucune activité enregistrée.</p>
|
||||||
|
) : (
|
||||||
|
activities.map((act) => (
|
||||||
|
<div key={act.id} className="flex items-start gap-3.5 p-3 rounded-xl bg-slate-800/20 border border-slate-800/50">
|
||||||
|
<div className="mt-0.5 w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center border border-slate-700 flex-shrink-0 overflow-hidden">
|
||||||
|
{act.avatar ? (
|
||||||
|
<img src={act.avatar} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : act.type === 'comment' ? (
|
||||||
|
<MessageCircle className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-semibold text-slate-200 truncate">{act.title}</p>
|
||||||
|
<span className="text-[10px] text-slate-500 flex-shrink-0">
|
||||||
|
{new Date(act.timestamp).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5 line-clamp-2">{act.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import DashboardClient from './DashboardClient';
|
import DashboardClient from './DashboardClient';
|
||||||
|
|
||||||
async function getStats() {
|
async function getDashboardData() {
|
||||||
const usersCount = await prisma.user.count();
|
const usersCount = await prisma.user.count();
|
||||||
const businessCount = await prisma.business.count();
|
const businessCount = await prisma.business.count();
|
||||||
const pendingCount = await prisma.business.count({ where: { verified: false } });
|
const pendingCount = await prisma.business.count({ where: { verified: false } });
|
||||||
@@ -21,10 +21,81 @@ async function getStats() {
|
|||||||
});
|
});
|
||||||
const uniqueVisitors = uniqueVisitorsRes.length;
|
const uniqueVisitors = uniqueVisitorsRes.length;
|
||||||
|
|
||||||
return { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors };
|
// Derniers entrepreneurs
|
||||||
|
const latestBusinesses = await prisma.business.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
category: true,
|
||||||
|
logoUrl: true,
|
||||||
|
location: true,
|
||||||
|
createdAt: true,
|
||||||
|
verified: true,
|
||||||
|
plan: true,
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activité récente (combiner commentaires et nouveaux utilisateurs)
|
||||||
|
const recentComments = await prisma.comment.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
createdAt: true,
|
||||||
|
author: { select: { name: true, avatar: true } },
|
||||||
|
business: { select: { name: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentUsers = await prisma.user.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
avatar: true,
|
||||||
|
createdAt: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mapper et trier
|
||||||
|
const activities = [
|
||||||
|
...recentComments.map(c => ({
|
||||||
|
id: `comment-${c.id}`,
|
||||||
|
type: 'comment' as const,
|
||||||
|
title: `${c.author?.name || 'Un utilisateur'} a commenté sur ${c.business?.name || 'une entreprise'}`,
|
||||||
|
subtitle: c.content.length > 60 ? c.content.substring(0, 60) + '...' : c.content,
|
||||||
|
timestamp: c.createdAt,
|
||||||
|
avatar: c.author?.avatar
|
||||||
|
})),
|
||||||
|
...recentUsers.map(u => ({
|
||||||
|
id: `user-${u.id}`,
|
||||||
|
type: 'user' as const,
|
||||||
|
title: `Nouvel utilisateur inscrit`,
|
||||||
|
subtitle: `${u.name} a rejoint la plateforme en tant que ${u.role === 'ENTREPRENEUR' ? 'Entrepreneur' : 'Visiteur'}`,
|
||||||
|
timestamp: u.createdAt,
|
||||||
|
avatar: u.avatar
|
||||||
|
}))
|
||||||
|
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()).slice(0, 6);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors },
|
||||||
|
latestBusinesses,
|
||||||
|
activities
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const stats = await getStats();
|
const data = await getDashboardData();
|
||||||
return <DashboardClient stats={stats} />;
|
return <DashboardClient initialData={data} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ body {
|
|||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
@@ -42,12 +43,16 @@ body {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
margin-left: 260px;
|
margin-left: 260px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
width: calc(100% - 260px);
|
||||||
|
max-width: calc(100vw - 260px);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
@@ -299,3 +304,23 @@ body {
|
|||||||
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
|
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
|
||||||
background-color: var(--primary);
|
background-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Styles pour les tableaux dans l'éditeur */
|
||||||
|
.quill-dark-wrapper .ql-editor table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-editor th,
|
||||||
|
.quill-dark-wrapper .ql-editor td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-editor th {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<Toaster position="bottom-right" />
|
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="admin-content flex-1">
|
<main className="admin-content flex-1 min-w-0">
|
||||||
|
<Toaster position="top-right" />
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Calendar
|
Calendar,
|
||||||
|
Map as MapIcon,
|
||||||
|
BookOpen
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import AnalyticsMap from '@/components/AnalyticsMap';
|
||||||
|
import CountryHistogram from '@/components/CountryHistogram';
|
||||||
|
|
||||||
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
|
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
|
||||||
if (value === null) return null;
|
if (value === null) return null;
|
||||||
@@ -76,12 +80,28 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
|||||||
|
|
||||||
const topCountries = await prisma.analyticsEvent.groupBy({
|
const topCountries = await prisma.analyticsEvent.groupBy({
|
||||||
by: ['country'],
|
by: ['country'],
|
||||||
where: currentWhere,
|
where: { type: 'PAGE_VIEW', ...currentWhere },
|
||||||
_count: { id: true },
|
_count: { id: true },
|
||||||
orderBy: { _count: { id: 'desc' } },
|
orderBy: { _count: { id: 'desc' } },
|
||||||
take: 10
|
take: 50 // Increased to get more map data
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unique visitors per country
|
||||||
|
const uniqueVisitorsPerCountryRaw = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['country', 'ip'],
|
||||||
|
where: { type: 'PAGE_VIEW', ...currentWhere }
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueVisitorsPerCountryMap: Record<string, number> = {};
|
||||||
|
uniqueVisitorsPerCountryRaw.forEach(item => {
|
||||||
|
const country = item.country || 'Unknown';
|
||||||
|
uniqueVisitorsPerCountryMap[country] = (uniqueVisitorsPerCountryMap[country] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueVisitorsPerCountry = Object.entries(uniqueVisitorsPerCountryMap)
|
||||||
|
.map(([country, count]) => ({ country, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
const devices = await prisma.analyticsEvent.groupBy({
|
const devices = await prisma.analyticsEvent.groupBy({
|
||||||
by: ['device'],
|
by: ['device'],
|
||||||
where: currentWhere,
|
where: currentWhere,
|
||||||
@@ -104,10 +124,63 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
|||||||
});
|
});
|
||||||
const uniqueVisitors = uniqueVisitorsRes.length;
|
const uniqueVisitors = uniqueVisitorsRes.length;
|
||||||
|
|
||||||
|
// Article page views (impressions)
|
||||||
|
const pageViewsByPath = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['path'],
|
||||||
|
where: {
|
||||||
|
type: 'PAGE_VIEW',
|
||||||
|
path: { startsWith: '/actualites/' },
|
||||||
|
...currentWhere
|
||||||
|
},
|
||||||
|
_count: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Article dwell times (reading times)
|
||||||
|
const dwellTimeByPath = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['path'],
|
||||||
|
where: {
|
||||||
|
type: 'DWELL_TIME',
|
||||||
|
path: { startsWith: '/actualites/' },
|
||||||
|
...currentWhere
|
||||||
|
},
|
||||||
|
_avg: { value: true },
|
||||||
|
_count: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Article unique visitors grouping
|
||||||
|
const uniqueVisitorsRaw = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['path', 'ip'],
|
||||||
|
where: {
|
||||||
|
type: 'PAGE_VIEW',
|
||||||
|
path: { startsWith: '/actualites/' },
|
||||||
|
...currentWhere
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load blog posts to associate with analytics
|
||||||
|
const blogPosts = await prisma.blogPost.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
author: true,
|
||||||
|
publishedAt: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalArticleViews = await prisma.analyticsEvent.count({
|
||||||
|
where: {
|
||||||
|
type: 'PAGE_VIEW',
|
||||||
|
path: { startsWith: '/actualites/' },
|
||||||
|
...currentWhere
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 4. Fetch Previous Data (for Deltas)
|
// 4. Fetch Previous Data (for Deltas)
|
||||||
let prevTotalEvents = 0;
|
let prevTotalEvents = 0;
|
||||||
let prevUniqueVisitors = 0;
|
let prevUniqueVisitors = 0;
|
||||||
let prevMobileCount = 0;
|
let prevMobileCount = 0;
|
||||||
|
let prevArticleViews = 0;
|
||||||
|
|
||||||
if (prevWhere) {
|
if (prevWhere) {
|
||||||
prevTotalEvents = await prisma.analyticsEvent.count({ where: prevWhere });
|
prevTotalEvents = await prisma.analyticsEvent.count({ where: prevWhere });
|
||||||
@@ -123,6 +196,14 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
|||||||
_count: { id: true }
|
_count: { id: true }
|
||||||
});
|
});
|
||||||
prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0;
|
prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0;
|
||||||
|
|
||||||
|
prevArticleViews = await prisma.analyticsEvent.count({
|
||||||
|
where: {
|
||||||
|
type: 'PAGE_VIEW',
|
||||||
|
path: { startsWith: '/actualites/' },
|
||||||
|
...prevWhere
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Recent Unique IPs
|
// 5. Recent Unique IPs
|
||||||
@@ -156,9 +237,53 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
|||||||
totalEvents,
|
totalEvents,
|
||||||
uniqueVisitors,
|
uniqueVisitors,
|
||||||
recentIPs,
|
recentIPs,
|
||||||
|
uniqueVisitorsPerCountry,
|
||||||
|
totalArticleViews,
|
||||||
|
articleStats: blogPosts.map(post => {
|
||||||
|
const paths = [`/actualites/${post.id}`];
|
||||||
|
if (post.slug) {
|
||||||
|
paths.push(`/actualites/${post.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let views = 0;
|
||||||
|
pageViewsByPath.forEach(item => {
|
||||||
|
if (paths.includes(item.path)) {
|
||||||
|
views += item._count.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueIps = new Set<string>();
|
||||||
|
uniqueVisitorsRaw.forEach(item => {
|
||||||
|
if (paths.includes(item.path) && item.ip) {
|
||||||
|
uniqueIps.add(item.ip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const visitors = uniqueIps.size;
|
||||||
|
|
||||||
|
let totalDwellTime = 0;
|
||||||
|
let dwellCount = 0;
|
||||||
|
dwellTimeByPath.forEach(item => {
|
||||||
|
if (paths.includes(item.path) && item._avg.value && item._count.id) {
|
||||||
|
totalDwellTime += item._avg.value * item._count.id;
|
||||||
|
dwellCount += item._count.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const avgDwellTime = dwellCount > 0 ? Math.round(totalDwellTime / dwellCount) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
author: post.author,
|
||||||
|
publishedAt: post.publishedAt,
|
||||||
|
views,
|
||||||
|
visitors,
|
||||||
|
avgDwellTime
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.views - a.views),
|
||||||
deltas: {
|
deltas: {
|
||||||
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
|
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
|
||||||
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
|
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
|
||||||
|
articleViews: prevArticleViews > 0 ? ((totalArticleViews - prevArticleViews) / prevArticleViews) * 100 : null,
|
||||||
mobile: prevTotalEvents > 0 && totalEvents > 0
|
mobile: prevTotalEvents > 0 && totalEvents > 0
|
||||||
? ((currentMobileCount / totalEvents) - (prevMobileCount / prevTotalEvents)) * 100
|
? ((currentMobileCount / totalEvents) - (prevMobileCount / prevTotalEvents)) * 100
|
||||||
: null
|
: null
|
||||||
@@ -169,7 +294,7 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
|||||||
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
|
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
|
||||||
const searchParams = await searchParamsPromise;
|
const searchParams = await searchParamsPromise;
|
||||||
const range = searchParams.range || 'week';
|
const range = searchParams.range || 'week';
|
||||||
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas } = await getMetrics(range, searchParams.from, searchParams.to);
|
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas, uniqueVisitorsPerCountry, totalArticleViews, articleStats } = await getMetrics(range, searchParams.from, searchParams.to);
|
||||||
|
|
||||||
const rangeLabels: Record<string, string> = {
|
const rangeLabels: Record<string, string> = {
|
||||||
day: 'Dernières 24h',
|
day: 'Dernières 24h',
|
||||||
@@ -230,20 +355,20 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
<div className="card border-t-4 border-indigo-500">
|
<div className="card border-t-4 border-indigo-500">
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
|
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
|
||||||
<Users className="w-6 h-6" />
|
<Users className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-2xl font-bold text-white">{uniqueVisitors}</div>
|
<div className="text-2xl font-bold text-white">{uniqueVisitors.toLocaleString()}</div>
|
||||||
<ComparisonBadge value={deltas.visitors} />
|
<ComparisonBadge value={deltas.visitors} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3>
|
<h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3>
|
||||||
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||||
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
Personnes réelles distinctes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -253,13 +378,29 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
|||||||
<Eye className="w-6 h-6" />
|
<Eye className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-2xl font-bold text-white">{totalEvents}</div>
|
<div className="text-2xl font-bold text-white">{totalEvents.toLocaleString()}</div>
|
||||||
<ComparisonBadge value={deltas.events} />
|
<ComparisonBadge value={deltas.events} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-slate-400 text-sm font-medium">Événements enregistrés</h3>
|
<h3 className="text-slate-400 text-sm font-medium">Impressions (Pages vues)</h3>
|
||||||
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||||
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
Nombre total de fois où les pages ont été vues
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card border-t-4 border-rose-500">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-400">
|
||||||
|
<BookOpen className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-white">{totalArticleViews.toLocaleString()}</div>
|
||||||
|
<ComparisonBadge value={deltas.articleViews} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-slate-400 text-sm font-medium">Articles consultés</h3>
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||||
|
Vues totales des articles de blog
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -275,13 +416,29 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
|||||||
<ComparisonBadge value={deltas.mobile} />
|
<ComparisonBadge value={deltas.mobile} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-slate-400 text-sm font-medium">Part du trafic Mobile</h3>
|
<h3 className="text-slate-400 text-sm font-medium">Trafic Mobile</h3>
|
||||||
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||||
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
Part des appareils mobiles
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* World Map Section */}
|
||||||
|
<AnalyticsMap
|
||||||
|
data={uniqueVisitorsPerCountry}
|
||||||
|
title="Répartition Géographique (Visiteurs Uniques)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Histogram Section */}
|
||||||
|
<CountryHistogram
|
||||||
|
title="Comparatif Impressions vs Visiteurs Uniques"
|
||||||
|
data={topCountries.map(c => ({
|
||||||
|
country: c.country || 'Inconnu',
|
||||||
|
impressions: c._count.id,
|
||||||
|
visitors: uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
{/* Top Countries Table */}
|
{/* Top Countries Table */}
|
||||||
<div className="card p-0 overflow-hidden">
|
<div className="card p-0 overflow-hidden">
|
||||||
@@ -294,18 +451,28 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
|||||||
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4">Pays</th>
|
<th className="px-6 py-4">Pays</th>
|
||||||
<th className="px-6 py-4 text-right">Visites</th>
|
<th className="px-6 py-4 text-right">Impressions</th>
|
||||||
|
<th className="px-6 py-4 text-right">V. Uniques</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800">
|
<tbody className="divide-y divide-slate-800">
|
||||||
{topCountries.map((c) => (
|
{topCountries.map((c) => {
|
||||||
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors">
|
const uniqueCount = uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0;
|
||||||
<td className="px-6 py-4 text-sm text-slate-300 font-bold">{c.country || 'Inconnu'}</td>
|
return (
|
||||||
<td className="px-6 py-4 text-right text-sm font-bold text-white">
|
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors text-xs">
|
||||||
{c._count.id}
|
<td className="px-6 py-4 text-slate-300 font-bold flex items-center gap-2">
|
||||||
</td>
|
<span className="w-2 h-2 rounded-full bg-indigo-500"></span>
|
||||||
</tr>
|
{c.country || 'Inconnu'}
|
||||||
))}
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right font-medium text-slate-400">
|
||||||
|
{c._count.id.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right text-sm font-bold text-white">
|
||||||
|
{uniqueCount.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,6 +547,53 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Article Statistics Table */}
|
||||||
|
<div className="card p-0 overflow-hidden lg:col-span-2">
|
||||||
|
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-rose-400" /> Statistiques par article
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4">Article</th>
|
||||||
|
<th className="px-6 py-4">Auteur</th>
|
||||||
|
<th className="px-6 py-4 text-right">Vues</th>
|
||||||
|
<th className="px-6 py-4 text-right">V. Uniques</th>
|
||||||
|
<th className="px-6 py-4 text-right">Temps de lecture moyen</th>
|
||||||
|
<th className="px-6 py-4 text-right">Date de publication</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{articleStats.map((post) => (
|
||||||
|
<tr key={post.id} className="hover:bg-slate-800/30 transition-colors text-xs">
|
||||||
|
<td className="px-6 py-4 font-semibold text-slate-200">
|
||||||
|
{post.title}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-slate-400">
|
||||||
|
{post.author}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right font-bold text-white">
|
||||||
|
{post.views.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right font-bold text-indigo-400">
|
||||||
|
{post.visitors.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right text-slate-300 font-mono">
|
||||||
|
{post.avgDwellTime > 0 ? `${post.avgDwellTime}s` : '---'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right text-slate-400">
|
||||||
|
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString('fr-FR') : 'Non publié'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recent IP Connections Table */}
|
{/* Recent IP Connections Table */}
|
||||||
<div className="card p-0 overflow-hidden lg:col-span-2">
|
<div className="card p-0 overflow-hidden lg:col-span-2">
|
||||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import { LayoutDashboard, ArrowLeft } from 'lucide-react';
|
|||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
|
||||||
<div className="max-w-md w-full text-center">
|
<div className="max-w-md w-full text-center space-y-8">
|
||||||
<div className="mb-8">
|
<div className="relative">
|
||||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 mb-6">
|
<h1 className="text-[150px] font-black text-slate-900 leading-none select-none">404</h1>
|
||||||
<span className="text-4xl font-bold text-indigo-400">404</span>
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-4xl font-bold text-white tracking-widest uppercase">Perdu ?</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Page d'administration introuvable</h1>
|
</div>
|
||||||
<p className="text-slate-400">La ressource que vous recherchez n'existe pas ou a été déplacée.</p>
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-2xl font-bold text-white">Cette page n'existe pas</h2>
|
||||||
|
<p className="text-slate-400">Le contenu que vous recherchez semble avoir été déplacé ou supprimé.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
|
|||||||
@@ -2,20 +2,32 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { getPricingPlans } from '@/app/actions/plans';
|
import { getPricingPlans } from '@/app/actions/plans';
|
||||||
|
import { getOneShotPlans } from '@/app/actions/one-shot';
|
||||||
import { CreditCard, Edit3, CheckCircle2, Star, Loader2, Zap } from 'lucide-react';
|
import { CreditCard, Edit3, CheckCircle2, Star, Loader2, Zap } from 'lucide-react';
|
||||||
import PlanEditModal from '@/components/PlanEditModal';
|
import PlanEditModal from '@/components/PlanEditModal';
|
||||||
|
import OneShotPricingSettings from '@/components/OneShotPricingSettings';
|
||||||
|
|
||||||
export default function PlansPage() {
|
export default function PlansPage() {
|
||||||
const [plans, setPlans] = useState<any[]>([]);
|
const [plans, setPlans] = useState<any[]>([]);
|
||||||
|
const [oneShotPlans, setOneShotPlans] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingPlan, setEditingPlan] = useState<any>(null);
|
const [editingPlan, setEditingPlan] = useState<any>(null);
|
||||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadPlans() {
|
async function loadPlans() {
|
||||||
const data = await getPricingPlans();
|
try {
|
||||||
setPlans(data);
|
const [plansData, oneShotData] = await Promise.all([
|
||||||
setLoading(false);
|
getPricingPlans(),
|
||||||
|
getOneShotPlans()
|
||||||
|
]);
|
||||||
|
setPlans(plansData);
|
||||||
|
setOneShotPlans(oneShotData || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load plans:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loadPlans();
|
loadPlans();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -32,8 +44,8 @@ export default function PlansPage() {
|
|||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Tarifs & Forfaits</h1>
|
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Forfaits & Services</h1>
|
||||||
<p className="text-slate-400">Modifiez les noms, prix et limites d'offres affichés sur le site.</p>
|
<p className="text-slate-400">Modifiez les abonnements mensuels et les services ponctuels (One-Shot).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle Billing */}
|
{/* Toggle Billing */}
|
||||||
@@ -129,6 +141,11 @@ export default function PlansPage() {
|
|||||||
currentCycle={billingCycle}
|
currentCycle={billingCycle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* One Shot Plans Section */}
|
||||||
|
<div className="mt-16 pt-16 border-t border-slate-800">
|
||||||
|
<OneShotPricingSettings initialPlans={oneShotPlans} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useTransition } from 'react';
|
import React, { useState, useEffect, useTransition } from 'react';
|
||||||
import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase } from 'lucide-react';
|
import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase, Plus, Trash2, Key, Terminal, Copy, Check } from 'lucide-react';
|
||||||
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
|
import { getSiteSettings, updateSiteSettings, getExternalApiKey } from '@/app/actions/settings';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { getBlogPosts } from '@/app/actions/blog';
|
import { getBlogPosts } from '@/app/actions/actualites';
|
||||||
import RichTextEditor from '@/components/RichTextEditor';
|
import RichTextEditor from '@/components/RichTextEditor';
|
||||||
|
|
||||||
const BUSINESS_CATEGORIES = [
|
const BUSINESS_CATEGORIES = [
|
||||||
@@ -34,10 +34,10 @@ const BLOG_CATEGORIES = [
|
|||||||
"Formation"
|
"Formation"
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsPage({
|
export default function SettingsPage({
|
||||||
searchParams
|
searchParams
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ tab?: string }>
|
searchParams: Promise<{ tab?: string }>
|
||||||
}) {
|
}) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [settings, setSettings] = useState<any>(null);
|
const [settings, setSettings] = useState<any>(null);
|
||||||
@@ -46,27 +46,43 @@ export default function SettingsPage({
|
|||||||
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
||||||
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
|
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
|
||||||
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
|
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
|
||||||
|
const [notFoundQuotes, setNotFoundQuotes] = useState<string[]>([]);
|
||||||
const [resetPasswordTemplate, setResetPasswordTemplate] = useState('');
|
const [resetPasswordTemplate, setResetPasswordTemplate] = useState('');
|
||||||
const [verifyEmailTemplate, setVerifyEmailTemplate] = useState('');
|
const [verifyEmailTemplate, setVerifyEmailTemplate] = useState('');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [apiOrigin, setApiOrigin] = useState('https://afroprenariat.com');
|
||||||
|
|
||||||
// Use a state for the tab if we want it to be fully client-side reactive without full reload
|
// Use a state for the tab if we want it to be fully client-side reactive without full reload
|
||||||
// But since the user wants it "like moderation center", I'll use URL search params
|
// But since the user wants it "like moderation center", I'll use URL search params
|
||||||
const [currentTab, setCurrentTab] = useState('general');
|
const [currentTab, setCurrentTab] = useState('general');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const [settingsData, postsData] = await Promise.all([
|
try {
|
||||||
getSiteSettings(),
|
const [settingsData, postsData, apiKeyData] = await Promise.all([
|
||||||
getBlogPosts()
|
getSiteSettings(),
|
||||||
]);
|
getBlogPosts(),
|
||||||
setSettings(settingsData);
|
getExternalApiKey()
|
||||||
setAllPosts(postsData);
|
]);
|
||||||
setSelectedPostIds(settingsData?.homeBlogIds || []);
|
setSettings(settingsData);
|
||||||
setSelectedHomeCategories(settingsData?.homeCategories || []);
|
setAllPosts(postsData);
|
||||||
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
|
setApiKey(apiKeyData);
|
||||||
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || '');
|
if (typeof window !== 'undefined') {
|
||||||
setVerifyEmailTemplate(settingsData?.verifyEmailTemplate || '');
|
setApiOrigin(window.location.origin);
|
||||||
setLoading(false);
|
}
|
||||||
|
setSelectedPostIds(settingsData?.homeBlogIds || []);
|
||||||
|
setSelectedHomeCategories(settingsData?.homeCategories || []);
|
||||||
|
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
|
||||||
|
setNotFoundQuotes(settingsData?.notFoundQuotes || ["L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."]);
|
||||||
|
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || '');
|
||||||
|
setVerifyEmailTemplate(settingsData?.verifyEmailTemplate || '');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load settings data:", error);
|
||||||
|
toast.error("Erreur lors du chargement des données");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -75,33 +91,35 @@ export default function SettingsPage({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const data = {
|
const data = {
|
||||||
siteName: formData.get('siteName'),
|
siteName: (formData.get('siteName') ?? settings?.siteName) as string,
|
||||||
siteSlogan: formData.get('siteSlogan'),
|
siteSlogan: (formData.get('siteSlogan') ?? settings?.siteSlogan) as string,
|
||||||
contactEmail: formData.get('contactEmail'),
|
contactEmail: (formData.get('contactEmail') ?? settings?.contactEmail) as string,
|
||||||
contactPhone: formData.get('contactPhone'),
|
contactPhone: (formData.get('contactPhone') ?? settings?.contactPhone) as string,
|
||||||
address: formData.get('address'),
|
address: (formData.get('address') ?? settings?.address) as string,
|
||||||
facebookUrl: formData.get('facebookUrl'),
|
facebookUrl: (formData.get('facebookUrl') ?? settings?.facebookUrl) as string,
|
||||||
twitterUrl: formData.get('twitterUrl'),
|
twitterUrl: (formData.get('twitterUrl') ?? settings?.twitterUrl) as string,
|
||||||
instagramUrl: formData.get('instagramUrl'),
|
instagramUrl: (formData.get('instagramUrl') ?? settings?.instagramUrl) as string,
|
||||||
linkedinUrl: formData.get('linkedinUrl'),
|
linkedinUrl: (formData.get('linkedinUrl') ?? settings?.linkedinUrl) as string,
|
||||||
footerText: formData.get('footerText'),
|
footerText: (formData.get('footerText') ?? settings?.footerText) as string,
|
||||||
homeBlogShow: formData.get('homeBlogShow') === 'on',
|
homeBlogShow: formData.has('homeBlogShow') ? formData.get('homeBlogShow') === 'on' : settings?.homeBlogShow,
|
||||||
homeBlogTitle: formData.get('homeBlogTitle'),
|
homeBlogTitle: (formData.get('homeBlogTitle') ?? settings?.homeBlogTitle) as string,
|
||||||
homeBlogSubtitle: formData.get('homeBlogSubtitle'),
|
homeBlogSubtitle: (formData.get('homeBlogSubtitle') ?? settings?.homeBlogSubtitle) as string,
|
||||||
homeBlogCount: parseInt(formData.get('homeBlogCount') as string || '3'),
|
homeBlogCount: formData.has('homeBlogCount') ? parseInt(formData.get('homeBlogCount') as string || '3') : settings?.homeBlogCount,
|
||||||
homeBlogSelection: formData.get('homeBlogSelection'),
|
homeBlogSelection: (formData.get('homeBlogSelection') ?? settings?.homeBlogSelection) as string,
|
||||||
homeBlogIds: selectedPostIds,
|
homeBlogIds: selectedPostIds,
|
||||||
homeBlogCategories: selectedBlogCategories,
|
homeBlogCategories: selectedBlogCategories,
|
||||||
homeCategories: selectedHomeCategories,
|
homeCategories: selectedHomeCategories,
|
||||||
resetPasswordSubject: formData.get('resetPasswordSubject'),
|
notFoundQuotes: notFoundQuotes.filter(q => q.trim() !== ''),
|
||||||
|
resetPasswordSubject: (formData.get('resetPasswordSubject') ?? settings?.resetPasswordSubject) as string,
|
||||||
resetPasswordTemplate: resetPasswordTemplate,
|
resetPasswordTemplate: resetPasswordTemplate,
|
||||||
verifyEmailSubject: formData.get('verifyEmailSubject'),
|
verifyEmailSubject: (formData.get('verifyEmailSubject') ?? settings?.verifyEmailSubject) as string,
|
||||||
verifyEmailTemplate: verifyEmailTemplate,
|
verifyEmailTemplate: verifyEmailTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updateSiteSettings(data);
|
const result = await updateSiteSettings(data);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
setSettings(result.settings);
|
||||||
toast.success("Paramètres mis à jour avec succès");
|
toast.success("Paramètres mis à jour avec succès");
|
||||||
} else {
|
} else {
|
||||||
toast.error("Erreur lors de la mise à jour");
|
toast.error("Erreur lors de la mise à jour");
|
||||||
@@ -110,23 +128,39 @@ export default function SettingsPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const togglePostId = (id: string) => {
|
const togglePostId = (id: string) => {
|
||||||
setSelectedPostIds(prev =>
|
setSelectedPostIds(prev =>
|
||||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleHomeCategory = (cat: string) => {
|
const toggleHomeCategory = (cat: string) => {
|
||||||
setSelectedHomeCategories(prev =>
|
setSelectedHomeCategories(prev =>
|
||||||
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
|
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleBlogCategory = (cat: string) => {
|
const toggleBlogCategory = (cat: string) => {
|
||||||
setSelectedBlogCategories(prev =>
|
setSelectedBlogCategories(prev =>
|
||||||
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
|
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddQuote = () => {
|
||||||
|
setNotFoundQuotes(prev => [...prev, '']);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateQuote = (index: number, value: string) => {
|
||||||
|
setNotFoundQuotes(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[index] = value;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveQuote = (index: number) => {
|
||||||
|
setNotFoundQuotes(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-20">
|
<div className="flex items-center justify-center p-20">
|
||||||
@@ -146,19 +180,29 @@ export default function SettingsPage({
|
|||||||
|
|
||||||
{/* Tabs Switcher */}
|
{/* Tabs Switcher */}
|
||||||
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentTab('general')}
|
type="button"
|
||||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'general' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
onClick={() => setCurrentTab('general')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'general' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
>
|
>
|
||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
Général
|
Général
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentTab('emails')}
|
type="button"
|
||||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'emails' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
onClick={() => setCurrentTab('emails')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'emails' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
>
|
>
|
||||||
<Mail className="w-4 h-4" />
|
<Mail className="w-4 h-4" />
|
||||||
Configuration Mail
|
Configuration Mail
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentTab('api')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'api' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
Intégration API
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,31 +218,82 @@ export default function SettingsPage({
|
|||||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Nom du site</label>
|
<label className="text-sm font-medium text-slate-400">Nom du site</label>
|
||||||
<input
|
<input
|
||||||
name="siteName"
|
name="siteName"
|
||||||
defaultValue={settings?.siteName}
|
defaultValue={settings?.siteName}
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Slogan du site</label>
|
<label className="text-sm font-medium text-slate-400">Slogan du site</label>
|
||||||
<input
|
<input
|
||||||
name="siteSlogan"
|
name="siteSlogan"
|
||||||
defaultValue={settings?.siteSlogan}
|
defaultValue={settings?.siteSlogan}
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2 space-y-2">
|
<div className="md:col-span-2 space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Texte du pied de page (Footer)</label>
|
<label className="text-sm font-medium text-slate-400">Texte du pied de page (Footer)</label>
|
||||||
<input
|
<input
|
||||||
name="footerText"
|
name="footerText"
|
||||||
defaultValue={settings?.footerText}
|
defaultValue={settings?.footerText}
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Section Page 404 - Phrases */}
|
||||||
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5 text-rose-400" />
|
||||||
|
<h2 className="font-semibold text-white">Phrases de la Page 404</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs bg-rose-500/10 text-rose-400 border border-rose-500/20 px-2.5 py-1 rounded-full font-bold">
|
||||||
|
{notFoundQuotes.length} configurée{notFoundQuotes.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Configurez ici les différentes phrases ou citations qui s'afficheront de manière aléatoire sur la page d'erreur 404.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{notFoundQuotes.map((quote, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={quote}
|
||||||
|
onChange={(e) => handleUpdateQuote(index, e.target.value)}
|
||||||
|
placeholder="Saisissez une citation..."
|
||||||
|
className="flex-grow bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 text-sm"
|
||||||
|
/>
|
||||||
|
{notFoundQuotes.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveQuote(index)}
|
||||||
|
className="p-3 bg-slate-950 border border-slate-700 hover:border-rose-500/50 text-slate-400 hover:text-rose-400 rounded-xl transition-all"
|
||||||
|
title="Supprimer cette phrase"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddQuote}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2.5 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 rounded-xl text-sm font-bold transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Ajouter une phrase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Section Page d'Accueil - Secteurs */}
|
{/* Section Page d'Accueil - Secteurs */}
|
||||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
@@ -213,11 +308,10 @@ export default function SettingsPage({
|
|||||||
key={cat}
|
key={cat}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleHomeCategory(cat)}
|
onClick={() => toggleHomeCategory(cat)}
|
||||||
className={`text-left p-2 rounded-lg border text-xs transition-all ${
|
className={`text-left p-2 rounded-lg border text-xs transition-all ${selectedHomeCategories.includes(cat)
|
||||||
selectedHomeCategories.includes(cat)
|
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
|
||||||
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
|
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||||
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
@@ -234,9 +328,9 @@ export default function SettingsPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex items-center gap-3 p-3 bg-indigo-600/10 border border-indigo-500/20 rounded-xl">
|
<div className="flex items-center gap-3 p-3 bg-indigo-600/10 border border-indigo-500/20 rounded-xl">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="homeBlogShow"
|
name="homeBlogShow"
|
||||||
id="homeBlogShow"
|
id="homeBlogShow"
|
||||||
defaultChecked={settings?.homeBlogShow}
|
defaultChecked={settings?.homeBlogShow}
|
||||||
className="w-5 h-5 rounded border-slate-700 bg-slate-950 text-indigo-600 focus:ring-indigo-500"
|
className="w-5 h-5 rounded border-slate-700 bg-slate-950 text-indigo-600 focus:ring-indigo-500"
|
||||||
@@ -247,18 +341,18 @@ export default function SettingsPage({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Titre de la section</label>
|
<label className="text-sm font-medium text-slate-400">Titre de la section</label>
|
||||||
<input
|
<input
|
||||||
name="homeBlogTitle"
|
name="homeBlogTitle"
|
||||||
defaultValue={settings?.homeBlogTitle}
|
defaultValue={settings?.homeBlogTitle}
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
|
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
|
||||||
<input
|
<input
|
||||||
name="homeBlogSubtitle"
|
name="homeBlogSubtitle"
|
||||||
defaultValue={settings?.homeBlogSubtitle}
|
defaultValue={settings?.homeBlogSubtitle}
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,12 +381,12 @@ export default function SettingsPage({
|
|||||||
<div className="grid grid-cols-1 gap-6">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Nombre maximum d'articles</label>
|
<label className="text-sm font-medium text-slate-400">Nombre maximum d'articles</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
name="homeBlogCount"
|
name="homeBlogCount"
|
||||||
defaultValue={settings?.homeBlogCount}
|
defaultValue={settings?.homeBlogCount}
|
||||||
min="1" max="12"
|
min="1" max="12"
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -305,11 +399,10 @@ export default function SettingsPage({
|
|||||||
key={post.id}
|
key={post.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => togglePostId(post.id)}
|
onClick={() => togglePostId(post.id)}
|
||||||
className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${
|
className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${selectedPostIds.includes(post.id)
|
||||||
selectedPostIds.includes(post.id)
|
? 'bg-indigo-600/20 border-indigo-500/50 text-white'
|
||||||
? 'bg-indigo-600/20 border-indigo-500/50 text-white'
|
: 'text-slate-400 hover:bg-slate-900'
|
||||||
: 'text-slate-400 hover:bg-slate-900'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span>{post.title}</span>
|
<span>{post.title}</span>
|
||||||
{selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</span>}
|
{selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</span>}
|
||||||
@@ -327,11 +420,10 @@ export default function SettingsPage({
|
|||||||
key={cat}
|
key={cat}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleBlogCategory(cat)}
|
onClick={() => toggleBlogCategory(cat)}
|
||||||
className={`px-4 py-2 rounded-full border text-xs transition-all ${
|
className={`px-4 py-2 rounded-full border text-xs transition-all ${selectedBlogCategories.includes(cat)
|
||||||
selectedBlogCategories.includes(cat)
|
? 'bg-purple-600 border-purple-500 text-white'
|
||||||
? 'bg-purple-600 border-purple-500 text-white'
|
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||||
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
@@ -353,10 +445,10 @@ export default function SettingsPage({
|
|||||||
<label className="text-sm font-medium text-slate-400">Email de support</label>
|
<label className="text-sm font-medium text-slate-400">Email de support</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Mail className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="contactEmail"
|
name="contactEmail"
|
||||||
defaultValue={settings?.contactEmail}
|
defaultValue={settings?.contactEmail}
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,10 +456,10 @@ export default function SettingsPage({
|
|||||||
<label className="text-sm font-medium text-slate-400">Téléphone de contact</label>
|
<label className="text-sm font-medium text-slate-400">Téléphone de contact</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Phone className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Phone className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="contactPhone"
|
name="contactPhone"
|
||||||
defaultValue={settings?.contactPhone}
|
defaultValue={settings?.contactPhone}
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,10 +467,10 @@ export default function SettingsPage({
|
|||||||
<label className="text-sm font-medium text-slate-400">Adresse physique</label>
|
<label className="text-sm font-medium text-slate-400">Adresse physique</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<MapPin className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<MapPin className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="address"
|
name="address"
|
||||||
defaultValue={settings?.address}
|
defaultValue={settings?.address}
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,11 +488,11 @@ export default function SettingsPage({
|
|||||||
<label className="text-sm font-medium text-slate-400">Facebook URL</label>
|
<label className="text-sm font-medium text-slate-400">Facebook URL</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="facebookUrl"
|
name="facebookUrl"
|
||||||
defaultValue={settings?.facebookUrl}
|
defaultValue={settings?.facebookUrl}
|
||||||
placeholder="https://facebook.com/..."
|
placeholder="https://facebook.com/..."
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,11 +500,11 @@ export default function SettingsPage({
|
|||||||
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
|
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="twitterUrl"
|
name="twitterUrl"
|
||||||
defaultValue={settings?.twitterUrl}
|
defaultValue={settings?.twitterUrl}
|
||||||
placeholder="https://twitter.com/..."
|
placeholder="https://twitter.com/..."
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,11 +512,11 @@ export default function SettingsPage({
|
|||||||
<label className="text-sm font-medium text-slate-400">Instagram URL</label>
|
<label className="text-sm font-medium text-slate-400">Instagram URL</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="instagramUrl"
|
name="instagramUrl"
|
||||||
defaultValue={settings?.instagramUrl}
|
defaultValue={settings?.instagramUrl}
|
||||||
placeholder="https://instagram.com/..."
|
placeholder="https://instagram.com/..."
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,11 +524,11 @@ export default function SettingsPage({
|
|||||||
<label className="text-sm font-medium text-slate-400">LinkedIn URL</label>
|
<label className="text-sm font-medium text-slate-400">LinkedIn URL</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="linkedinUrl"
|
name="linkedinUrl"
|
||||||
defaultValue={settings?.linkedinUrl}
|
defaultValue={settings?.linkedinUrl}
|
||||||
placeholder="https://linkedin.com/in/..."
|
placeholder="https://linkedin.com/in/..."
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -459,17 +551,17 @@ export default function SettingsPage({
|
|||||||
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">Réinitialisation de mot de passe</h3>
|
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">Réinitialisation de mot de passe</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
|
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
|
||||||
<input
|
<input
|
||||||
name="resetPasswordSubject"
|
name="resetPasswordSubject"
|
||||||
defaultValue={settings?.resetPasswordSubject}
|
defaultValue={settings?.resetPasswordSubject}
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
|
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
value={resetPasswordTemplate}
|
value={resetPasswordTemplate}
|
||||||
onChange={setResetPasswordTemplate}
|
onChange={setResetPasswordTemplate}
|
||||||
placeholder="Contenu de l'email..."
|
placeholder="Contenu de l'email..."
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-slate-500">
|
<p className="text-[10px] text-slate-500">
|
||||||
@@ -485,17 +577,17 @@ export default function SettingsPage({
|
|||||||
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider">Vérification de compte</h3>
|
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider">Vérification de compte</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
|
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
|
||||||
<input
|
<input
|
||||||
name="verifyEmailSubject"
|
name="verifyEmailSubject"
|
||||||
defaultValue={settings?.verifyEmailSubject}
|
defaultValue={settings?.verifyEmailSubject}
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
|
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
value={verifyEmailTemplate}
|
value={verifyEmailTemplate}
|
||||||
onChange={setVerifyEmailTemplate}
|
onChange={setVerifyEmailTemplate}
|
||||||
placeholder="Contenu de l'email..."
|
placeholder="Contenu de l'email..."
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-slate-500">
|
<p className="text-[10px] text-slate-500">
|
||||||
@@ -508,20 +600,120 @@ export default function SettingsPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
{currentTab === 'api' && (
|
||||||
<button
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
type="submit"
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
disabled={isPending}
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white px-8 py-3 rounded-xl font-bold transition-all shadow-lg flex items-center gap-2"
|
<Key className="w-5 h-5 text-amber-400" />
|
||||||
>
|
<h2 className="font-semibold text-white">Clé d'API Externe</h2>
|
||||||
{isPending ? (
|
</div>
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<div className="p-6 space-y-4">
|
||||||
) : (
|
<p className="text-sm text-slate-400">
|
||||||
<Save className="w-5 h-5" />
|
Utilisez cette clé API pour authentifier vos requêtes lors de la création d'articles depuis un système externe.
|
||||||
)}
|
</p>
|
||||||
Enregistrer les modifications
|
|
||||||
</button>
|
<div className="flex items-center gap-2 max-w-xl">
|
||||||
</div>
|
<div className="flex-grow bg-slate-950 border border-slate-700 rounded-xl p-3 text-white font-mono text-sm overflow-x-auto select-all">
|
||||||
|
{apiKey || "Chargement de la clé..."}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(apiKey);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success("Clé API copiée !");
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}}
|
||||||
|
className="p-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl transition-all flex items-center gap-2 font-semibold text-sm cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
{copied ? "Copié" : "Copier"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Gardez cette clé confidentielle. Ne la partagez pas dans des environnements publics ou côté client.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
|
<Terminal className="w-5 h-5 text-indigo-400" />
|
||||||
|
<h2 className="font-semibold text-white">Documentation de l'API de Création d'Articles</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-slate-300 mb-2">Endpoint</h3>
|
||||||
|
<div className="bg-slate-950 border border-slate-700 rounded-xl p-3 flex items-center gap-2 font-mono text-xs text-white">
|
||||||
|
<span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded font-bold">POST</span>
|
||||||
|
<span className="text-slate-300">{apiOrigin}/api/external/articles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-slate-300 mb-2">Headers requis</h3>
|
||||||
|
<div className="bg-slate-950 border border-slate-700 rounded-xl p-4 font-mono text-xs text-slate-300 space-y-1">
|
||||||
|
<div><span className="text-indigo-400">Content-Type</span>: application/json</div>
|
||||||
|
<div><span className="text-indigo-400">x-api-key</span>: <span className="text-amber-400">{"{VOTRE_CLE_API}"}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-slate-300 mb-2">Corps de la requête (JSON)</h3>
|
||||||
|
<pre className="bg-slate-950 border border-slate-700 rounded-xl p-4 font-mono text-xs text-slate-300 overflow-x-auto">
|
||||||
|
{`{
|
||||||
|
"title": "Un nouvel article sur l'écosystème",
|
||||||
|
"excerpt": "Le résumé de l'article visible sur la liste.",
|
||||||
|
"content": "<h1>Contenu de l'article</h1><p>Corps de l'article en HTML ou texte brut.</p>",
|
||||||
|
"author": "Afropreneur API",
|
||||||
|
"imageUrl": "https://images.unsplash.com/photo-1516321318423-f06f85e504b3",
|
||||||
|
"type": "Actualité",
|
||||||
|
"tags": ["Technologie", "Innovation"],
|
||||||
|
"status": "PUBLISHED",
|
||||||
|
"publishedAt": "${new Date().toISOString()}"
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-slate-300 mb-2">Exemple de requête (cURL)</h3>
|
||||||
|
<pre className="bg-slate-950 border border-slate-700 rounded-xl p-4 font-mono text-xs text-slate-300 overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{`curl -X POST "${apiOrigin}/api/external/articles" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "x-api-key: ${apiKey || "VOTRE_CLE_API"}" \\
|
||||||
|
-d '{
|
||||||
|
"title": "Un nouvel article sur l ecosystème",
|
||||||
|
"excerpt": "Le résumé de l article.",
|
||||||
|
"content": "Le corps de l article.",
|
||||||
|
"author": "Afropreneur API",
|
||||||
|
"imageUrl": "https://images.unsplash.com/photo-1516321318423-f06f85e504b3",
|
||||||
|
"type": "Actualité",
|
||||||
|
"tags": ["Tech", "Startup"],
|
||||||
|
"status": "PUBLISHED"
|
||||||
|
}'`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTab !== 'api' && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white px-8 py-3 rounded-xl font-bold transition-all shadow-lg flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
Enregistrer les modifications
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
18
admin/src/app/slides/edit/[id]/page.tsx
Normal file
18
admin/src/app/slides/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import SlideForm from "@/components/SlideForm";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function EditSlidePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const slide = await prisma.homeSlide.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { business: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!slide) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SlideForm initialData={slide} />;
|
||||||
|
}
|
||||||
5
admin/src/app/slides/new/page.tsx
Normal file
5
admin/src/app/slides/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import SlideForm from "@/components/SlideForm";
|
||||||
|
|
||||||
|
export default function NewSlidePage() {
|
||||||
|
return <SlideForm />;
|
||||||
|
}
|
||||||
115
admin/src/app/slides/page.tsx
Normal file
115
admin/src/app/slides/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Plus, Image as ImageIcon, Trash2, Edit, ExternalLink, Power, PowerOff } from 'lucide-react';
|
||||||
|
import DeleteSlideButton from '@/components/DeleteSlideButton';
|
||||||
|
import ToggleSlideStatusButton from '@/components/ToggleSlideStatusButton';
|
||||||
|
|
||||||
|
async function getData() {
|
||||||
|
try {
|
||||||
|
return await prisma.homeSlide.findMany({
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: { business: true }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch slides:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SlidesPage() {
|
||||||
|
const slides = await getData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-end mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Gestion de la Bannière</h1>
|
||||||
|
<p className="text-slate-400">Gérez les slides qui s'affichent sur la page d'accueil (Slider).</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/slides/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nouvelle Slide
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<ImageIcon className="text-indigo-400 w-5 h-5" />
|
||||||
|
<h2 className="text-xl font-bold text-white">Slides Actuelles ({slides.length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slides.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-slate-800/20 rounded-xl border border-dashed border-slate-700">
|
||||||
|
<p className="text-slate-500">Aucune slide configurée. Le bandeau par défaut sera affiché.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-3 px-4">Ordre</th>
|
||||||
|
<th className="text-left py-3 px-4">Type</th>
|
||||||
|
<th className="text-left py-3 px-4">Contenu</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{slides.map((slide) => (
|
||||||
|
<tr key={slide.id} className="hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<span className="text-white font-mono bg-slate-800 px-2 py-1 rounded">#{slide.order}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${slide.type === 'BUSINESS' ? 'bg-indigo-500/10 text-indigo-400 border border-indigo-500/20' : 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'}`}>
|
||||||
|
{slide.type === 'BUSINESS' ? 'ENTREPRISE' : 'PERSONNALISÉ'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{slide.type === 'BUSINESS' ? (
|
||||||
|
<>
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
|
||||||
|
<img src={slide.business?.logoUrl || '/placeholder-biz.png'} className="w-full h-full object-cover" alt="" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">{slide.business?.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{slide.business?.location}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
|
||||||
|
<img src={slide.imageUrl || '/placeholder-img.png'} className="w-full h-full object-cover" alt="" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">{slide.title || 'Sans titre'}</div>
|
||||||
|
<div className="text-xs text-slate-500 truncate max-w-[250px]">{slide.linkUrl || 'Pas de lien'}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<ToggleSlideStatusButton id={slide.id} currentStatus={slide.isActive} />
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link href={`/slides/edit/${slide.id}`} className="p-2 text-slate-400 hover:text-indigo-400 transition-colors">
|
||||||
|
<Edit className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<DeleteSlideButton id={slide.id} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,15 @@ import { prisma } from '@/lib/prisma';
|
|||||||
import { getClients } from "@/app/actions/user";
|
import { getClients } from "@/app/actions/user";
|
||||||
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
||||||
import FeaturedModal from '@/components/FeaturedModal';
|
import FeaturedModal from '@/components/FeaturedModal';
|
||||||
|
import ToggleHomeFeatureButton from '@/components/ToggleHomeFeatureButton';
|
||||||
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
||||||
import PlanSelector from '@/components/PlanSelector';
|
import PlanSelector from '@/components/PlanSelector';
|
||||||
import ClientActions from "@/components/ClientActions";
|
import ClientActions from "@/components/ClientActions";
|
||||||
import ManualVerifyButton from "@/components/ManualVerifyButton";
|
import ManualVerifyButton from "@/components/ManualVerifyButton";
|
||||||
import { ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
|
import { Trash2, ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
// Force Turbopack native load
|
||||||
async function getBusinesses() {
|
async function getBusinesses() {
|
||||||
return await prisma.business.findMany({
|
return await prisma.business.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -87,30 +89,32 @@ export default async function UsersPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card overflow-hidden p-0">
|
<div className="card overflow-hidden p-0">
|
||||||
<table className="data-table">
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table min-w-[1200px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Entreprise</th>
|
<th className="sticky left-0 z-30 bg-[#1e293b] border-r border-slate-700">Entreprise</th>
|
||||||
<th>Catégorie</th>
|
<th>Catégorie</th>
|
||||||
<th>Propriétaire</th>
|
<th>Propriétaire</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Statut</th>
|
<th>Statut</th>
|
||||||
<th className="text-center">Forfait</th>
|
<th className="text-center">Forfait</th>
|
||||||
<th className="text-center">Mise en avant</th>
|
<th className="text-center whitespace-nowrap">À la une (Accueil)</th>
|
||||||
|
<th className="text-center whitespace-nowrap">Afroshine (Annuaire)</th>
|
||||||
<th className="text-right">Action</th>
|
<th className="text-right">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{businesses.length === 0 ? (
|
{businesses.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="text-center py-10 text-slate-500">
|
<td colSpan={9} className="text-center py-10 text-slate-500">
|
||||||
Aucun entrepreneur trouvé.
|
Aucun entrepreneur trouvé.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
businesses.map((business: any) => (
|
businesses.map((business: any) => (
|
||||||
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
|
<tr key={business.id} className={`transition-colors ${business.owner?.deletedAt ? 'bg-red-500/5' : (business.isSuspended || business.owner?.isSuspended) ? 'grayscale-[0.5]' : ''}`}>
|
||||||
<td>
|
<td className="sticky left-0 z-20 bg-[#1e293b] border-r border-slate-700">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
|
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
|
||||||
{business.logoUrl ? (
|
{business.logoUrl ? (
|
||||||
@@ -129,11 +133,11 @@ export default async function UsersPage({
|
|||||||
<span className="text-sm text-slate-300">{business.category}</span>
|
<span className="text-sm text-slate-300">{business.category}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="text-sm text-white">{business.owner.name}</div>
|
<div className="text-sm text-white">{business.owner?.name || 'Inconnu'}</div>
|
||||||
<div className="text-xs text-slate-500">{business.owner.email}</div>
|
<div className="text-xs text-slate-500">{business.owner?.email || 'N/A'}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{business.owner.emailVerified ? (
|
{business.owner?.emailVerified ? (
|
||||||
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
|
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
|
||||||
<MailCheck className="w-3.5 h-3.5" /> Vérifié
|
<MailCheck className="w-3.5 h-3.5" /> Vérifié
|
||||||
</span>
|
</span>
|
||||||
@@ -142,13 +146,17 @@ export default async function UsersPage({
|
|||||||
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
|
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
|
||||||
<MailX className="w-3.5 h-3.5" /> Non vérifié
|
<MailX className="w-3.5 h-3.5" /> Non vérifié
|
||||||
</span>
|
</span>
|
||||||
<ManualVerifyButton userId={business.owner.id} />
|
{business.owner?.id && <ManualVerifyButton userId={business.owner.id} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{business.owner?.isSuspended ? (
|
{business.owner?.deletedAt ? (
|
||||||
|
<span className="badge bg-red-500/10 text-red-500 border border-red-500/20 flex items-center gap-1">
|
||||||
|
<Trash2 className="w-3 h-3" /> Suppression en cours
|
||||||
|
</span>
|
||||||
|
) : business.owner?.isSuspended ? (
|
||||||
<span className="badge badge-error flex items-center gap-1">
|
<span className="badge badge-error flex items-center gap-1">
|
||||||
<ShieldX className="w-3 h-3" /> Compte Suspendu
|
<ShieldX className="w-3 h-3" /> Compte Suspendu
|
||||||
</span>
|
</span>
|
||||||
@@ -166,6 +174,9 @@ export default async function UsersPage({
|
|||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<PlanSelector businessId={business.id} currentPlan={business.plan} />
|
<PlanSelector businessId={business.id} currentPlan={business.plan} />
|
||||||
</td>
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<ToggleHomeFeatureButton id={business.id} isHomeFeatured={!!business.isHomeFeatured} />
|
||||||
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<FeaturedModal business={business} />
|
<FeaturedModal business={business} />
|
||||||
</td>
|
</td>
|
||||||
@@ -176,8 +187,8 @@ export default async function UsersPage({
|
|||||||
<EntrepreneurActions
|
<EntrepreneurActions
|
||||||
businessId={business.id}
|
businessId={business.id}
|
||||||
businessName={business.name}
|
businessName={business.name}
|
||||||
userId={business.owner.id}
|
userId={business.owner?.id || ''}
|
||||||
userName={business.owner.name}
|
userName={business.owner?.name || 'Inconnu'}
|
||||||
isBusinessSuspended={!!business.isSuspended}
|
isBusinessSuspended={!!business.isSuspended}
|
||||||
isUserSuspended={!!business.owner?.isSuspended}
|
isUserSuspended={!!business.owner?.isSuspended}
|
||||||
metaTitle={business.metaTitle}
|
metaTitle={business.metaTitle}
|
||||||
@@ -190,15 +201,16 @@ export default async function UsersPage({
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="data-table">
|
<table className="data-table min-w-[1000px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Utilisateur</th>
|
<th className="sticky left-0 z-30 bg-[#1e293b] border-r border-slate-700">Utilisateur</th>
|
||||||
<th>Contact</th>
|
<th>Contact</th>
|
||||||
<th>Vérification</th>
|
<th>Vérification</th>
|
||||||
<th>Localisation</th>
|
<th>Localisation</th>
|
||||||
@@ -218,8 +230,8 @@ export default async function UsersPage({
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
clients.map((client: any) => (
|
clients.map((client: any) => (
|
||||||
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
|
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.deletedAt ? 'bg-red-500/5' : client.isSuspended ? 'grayscale-[0.5]' : ''}`}>
|
||||||
<td className="py-4">
|
<td className="sticky left-0 z-20 bg-[#1e293b] border-r border-slate-700 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
|
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
|
||||||
{client.avatar ? (
|
{client.avatar ? (
|
||||||
@@ -270,7 +282,11 @@ export default async function UsersPage({
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{client.isSuspended ? (
|
{client.deletedAt ? (
|
||||||
|
<span className="badge bg-red-500/10 text-red-500 border border-red-500/20 flex items-center gap-1">
|
||||||
|
<Trash2 className="w-3 h-3" /> Suppression en cours
|
||||||
|
</span>
|
||||||
|
) : client.isSuspended ? (
|
||||||
<>
|
<>
|
||||||
<span className="badge badge-error">Compte Suspendu</span>
|
<span className="badge badge-error">Compte Suspendu</span>
|
||||||
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
|
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
|
||||||
|
|||||||
434
admin/src/components/ActualitesForm.tsx
Normal file
434
admin/src/components/ActualitesForm.tsx
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createBlogPost, updateBlogPost } from '@/app/actions/actualites';
|
||||||
|
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive, Link as LinkIcon, Plus, Trash2, FileJson } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import RichTextEditor from './RichTextEditor';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
import { ContentStatus } from '@prisma/client';
|
||||||
|
import { getAllTags } from '@/app/actions/taxonomies';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialData?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
imageUrl: string;
|
||||||
|
slug?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
metaTitle?: string | null;
|
||||||
|
metaDescription?: string | null;
|
||||||
|
publishedAt?: Date | string | null;
|
||||||
|
status?: ContentStatus;
|
||||||
|
coverPosition?: string | null;
|
||||||
|
coverZoom?: number | null;
|
||||||
|
sources?: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlogForm({ initialData }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// JSON prefill state
|
||||||
|
const [currentData, setCurrentData] = useState<any>(initialData || {});
|
||||||
|
const [jsonString, setJsonString] = useState('');
|
||||||
|
const [showJsonInput, setShowJsonInput] = useState(false);
|
||||||
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form input states
|
||||||
|
const [content, setContent] = useState(initialData?.content || '');
|
||||||
|
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
||||||
|
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
|
||||||
|
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
|
||||||
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
|
initialData?.publishedAt
|
||||||
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
||||||
|
: new Date().toISOString().slice(0, 16)
|
||||||
|
);
|
||||||
|
const [sources, setSources] = useState<{ title: string; url: string }[]>(
|
||||||
|
Array.isArray(initialData?.sources) ? initialData.sources : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllTags().then(setAllExistingTags);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleApplyJson = () => {
|
||||||
|
try {
|
||||||
|
if (!jsonString.trim()) {
|
||||||
|
throw new Error("Veuillez coller un code JSON.");
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
|
|
||||||
|
// Extract new combined tags
|
||||||
|
const newTags = Array.isArray(parsed.tags) ? [...parsed.tags] : [...tags];
|
||||||
|
if (parsed.type && !newTags.includes(parsed.type)) {
|
||||||
|
newTags.push(parsed.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update states to prefill rich components
|
||||||
|
if (parsed.content) setContent(parsed.content);
|
||||||
|
if (parsed.imageUrl) setImageUrl(parsed.imageUrl);
|
||||||
|
setTags(newTags);
|
||||||
|
if (Array.isArray(parsed.sources)) {
|
||||||
|
setSources(parsed.sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update currentData to reset defaultValues on native inputs via form key remount
|
||||||
|
setCurrentData({
|
||||||
|
...currentData,
|
||||||
|
title: parsed.title || currentData.title || '',
|
||||||
|
excerpt: parsed.excerpt || currentData.excerpt || '',
|
||||||
|
author: parsed.author || currentData.author || 'Rédaction Afrohub',
|
||||||
|
slug: parsed.slug || currentData.slug || '',
|
||||||
|
metaTitle: parsed.metaTitle || currentData.metaTitle || '',
|
||||||
|
metaDescription: parsed.metaDescription || currentData.metaDescription || '',
|
||||||
|
_prefillKey: Date.now() // Force form remount
|
||||||
|
});
|
||||||
|
|
||||||
|
setJsonError(null);
|
||||||
|
setShowJsonInput(false);
|
||||||
|
setJsonString('');
|
||||||
|
toast.success("Champs pré-remplis avec le JSON !");
|
||||||
|
} catch (err: any) {
|
||||||
|
setJsonError("Format JSON invalide. Vérifiez la syntaxe : " + (err.message || ''));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const publishedAtStr = formData.get('publishedAt') as string;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: formData.get('title') as string,
|
||||||
|
slug: formData.get('slug') as string,
|
||||||
|
excerpt: formData.get('excerpt') as string,
|
||||||
|
content: content,
|
||||||
|
author: formData.get('author') as string,
|
||||||
|
imageUrl: imageUrl, // Use state value
|
||||||
|
tags: tags, // Use state value
|
||||||
|
metaTitle: formData.get('metaTitle') as string,
|
||||||
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
|
status: formData.get('status') as ContentStatus,
|
||||||
|
coverPosition: coverPosition,
|
||||||
|
coverZoom: coverZoom,
|
||||||
|
sources: sources,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.imageUrl) {
|
||||||
|
toast.error("Une image est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = initialData
|
||||||
|
? await updateBlogPost(initialData.id, data)
|
||||||
|
: await createBlogPost(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
|
||||||
|
router.push('/actualites');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto pb-20">
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/actualites" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{initialData ? 'Modifier l\'article' : 'Nouvel Article'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PREFILL JSON SECTION */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 mb-8 shadow-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowJsonInput(!showJsonInput)}
|
||||||
|
className="flex items-center justify-between w-full text-left font-bold text-sm text-brand-400 hover:text-brand-300 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<FileJson className="w-5 h-5 text-indigo-400" />
|
||||||
|
⚡ Importer JSON (Pré-remplir les champs automatiquement)
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-slate-800 px-2.5 py-1 rounded text-slate-400 border border-slate-700">
|
||||||
|
{showJsonInput ? 'Masquer' : 'Déplier'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showJsonInput && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-800 space-y-3 animate-in fade-in duration-200">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Collez le code JSON généré par Gemini pour remplir instantanément le titre, l'extrait, le contenu HTML, l'auteur, l'image, le slug, les tags SEO, et les sources.
|
||||||
|
</p>
|
||||||
|
{jsonError && (
|
||||||
|
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 p-2.5 rounded-lg font-medium">
|
||||||
|
{jsonError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={jsonString}
|
||||||
|
onChange={(e) => setJsonString(e.target.value)}
|
||||||
|
placeholder={'{\n "title": "Les 5 tendances de la Tech Africaine en 2026",\n "excerpt": "Découvrez comment l\'intelligence artificielle...",\n "content": "<p>L\'année 2026 marque un tournant...</p>",\n "author": "Rédaction Afrohub",\n "imageUrl": "https://images.unsplash.com/photo-...",\n "slug": "tendances-tech-afrique-2026",\n "tags": ["Technologie", "IA", "FinTech"],\n "metaTitle": "Les 5 tendances Tech en Afrique - Afrohub",\n "metaDescription": "Analyse complète des tendances de l\'écosystème tech...",\n "sources": [\n { "title": "Rapport BAD 2026", "url": "https://afdb.org" }\n ]\n}'}
|
||||||
|
rows={8}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-xs font-mono text-emerald-400 focus:outline-none focus:border-indigo-500"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setJsonString('{\n "title": "Les 5 tendances de la Tech Africaine en 2026",\n "excerpt": "Découvrez comment l\'intelligence artificielle et la FinTech redéfinissent l\'écosystème entrepreneurial sur le continent cette année.",\n "content": "<p>L\'année 2026 marque un tournant décisif. Les startups...</p><h3>L\'IA au service de l\'agriculture</h3><p>De nouvelles solutions émergent...</p>",\n "author": "Rédaction Afrohub",\n "imageUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&q=80&w=1200",\n "slug": "tendances-tech-afrique-2026",\n "tags": ["Technologie", "IA", "FinTech"],\n "metaTitle": "Les 5 tendances Tech en Afrique - Afrohub",\n "metaDescription": "Analyse complète des tendances de l\'écosystème tech et des financements sur le continent africain en 2026.",\n "sources": [\n { "title": "Rapport BAD 2026", "url": "https://afdb.org" },\n { "title": "Jeune Afrique", "url": "https://jeuneafrique.com" }\n ]\n}');
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Exemple complet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyJson}
|
||||||
|
className="px-4 py-1.5 text-xs font-bold bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Appliquer les données
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form key={currentData._prefillKey || 'form'} onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={currentData.status || 'PUBLISHED'}
|
||||||
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">Brouillon</option>
|
||||||
|
<option value="PUBLISHED">Publié</option>
|
||||||
|
<option value="ARCHIVED">Archivé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Titre</label>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
defaultValue={currentData.title}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: L'essor de la Tech en Afrique"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Auteur</label>
|
||||||
|
<input
|
||||||
|
name="author"
|
||||||
|
defaultValue={currentData.author || (initialData ? undefined : 'Rédaction Afrohub')}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: Jean Dupont"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ImageUploader
|
||||||
|
label="Image de couverture"
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={setImageUrl}
|
||||||
|
name="imageUrl"
|
||||||
|
showPositionControls={true}
|
||||||
|
position={coverPosition}
|
||||||
|
zoom={coverZoom}
|
||||||
|
onPositionChange={setCoverPosition}
|
||||||
|
onZoomChange={setCoverZoom}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">
|
||||||
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="publishedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={publishedAtValue}
|
||||||
|
onChange={(e) => setPublishedAtValue(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
|
||||||
|
<textarea
|
||||||
|
name="excerpt"
|
||||||
|
defaultValue={currentData.excerpt}
|
||||||
|
required
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Un court résumé de l'article..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={content}
|
||||||
|
onChange={setContent}
|
||||||
|
placeholder="Rédigez votre article ici..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SOURCES SECTION */}
|
||||||
|
<div className="pt-6 border-t border-slate-800">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<label className="text-sm font-medium text-slate-400 flex items-center gap-2">
|
||||||
|
<LinkIcon className="w-4 h-4 text-brand-500" />
|
||||||
|
Sources & Liens externes
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSources([...sources, { title: '', url: '' }])}
|
||||||
|
className="text-xs font-bold text-brand-500 hover:text-brand-400 flex items-center gap-1 bg-brand-500/10 px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
Ajouter une source
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sources.map((source, index) => (
|
||||||
|
<div key={index} className="flex gap-3 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
<input
|
||||||
|
placeholder="Nom de la source (ex: Le Monde)"
|
||||||
|
value={source.title}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSources = [...sources];
|
||||||
|
newSources[index].title = e.target.value;
|
||||||
|
setSources(newSources);
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="URL (ex: https://...)"
|
||||||
|
value={source.url}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSources = [...sources];
|
||||||
|
newSources[index].url = e.target.value;
|
||||||
|
setSources(newSources);
|
||||||
|
}}
|
||||||
|
className="flex-2 bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSources(sources.filter((_, i) => i !== index))}
|
||||||
|
className="p-2.5 text-slate-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sources.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-600 italic">Aucune source ajoutée pour cet article.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SEO SECTION */}
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
|
||||||
|
<input
|
||||||
|
name="slug"
|
||||||
|
defaultValue={currentData.slug || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="ex: lessor-de-la-tech-afrique"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<TagInput
|
||||||
|
value={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
suggestions={allExistingTags}
|
||||||
|
label="Mots-clés (tags)"
|
||||||
|
placeholder="tech, innovation, afrique..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
|
||||||
|
<input
|
||||||
|
name="metaTitle"
|
||||||
|
defaultValue={currentData.metaTitle || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Titre optimisé pour les moteurs de recherche"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
|
||||||
|
<textarea
|
||||||
|
name="metaDescription"
|
||||||
|
defaultValue={currentData.metaDescription || ''}
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Description optimisée pour les moteurs de recherche (max 160 caractères)..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
{initialData ? 'Enregistrer les modifications' : 'Créer l\'article'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
admin/src/components/AnalyticsMap.tsx
Normal file
210
admin/src/components/AnalyticsMap.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
admin/src/components/ApproveContentButton.tsx
Normal file
61
admin/src/components/ApproveContentButton.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useTransition } from 'react';
|
||||||
|
import { Check, X, Loader2 } from 'lucide-react';
|
||||||
|
import { approveBlogPost, approveEvent, rejectBlogPost, rejectEvent } from '@/app/actions/approval';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface ApproveContentButtonProps {
|
||||||
|
id: string;
|
||||||
|
type: 'post' | 'event';
|
||||||
|
action: 'approve' | 'reject';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApproveContentButton({ id, type, action }: ApproveContentButtonProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleAction = async () => {
|
||||||
|
if (action === 'reject' && !confirm("Êtes-vous sûr de vouloir rejeter ce contenu ?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
let result;
|
||||||
|
if (type === 'post') {
|
||||||
|
result = action === 'approve' ? await approveBlogPost(id) : await rejectBlogPost(id);
|
||||||
|
} else {
|
||||||
|
result = action === 'approve' ? await approveEvent(id) : await rejectEvent(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(action === 'approve' ? "Contenu approuvé et publié" : "Contenu rejeté");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (action === 'approve') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleAction}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-2 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
title="Approuver et Publier"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Check className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleAction}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-2 bg-red-500/10 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
title="Rejeter"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <X className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useTransition, useState, useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
|
|
||||||
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import RichTextEditor from './RichTextEditor';
|
|
||||||
import ImageUploader from './ImageUploader';
|
|
||||||
import TagInput from './TagInput';
|
|
||||||
import { ContentStatus } from '@prisma/client';
|
|
||||||
import { getAllTags } from '@/app/actions/taxonomies';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
initialData?: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
excerpt: string;
|
|
||||||
content: string;
|
|
||||||
author: string;
|
|
||||||
imageUrl: string;
|
|
||||||
slug?: string | null;
|
|
||||||
tags?: string[];
|
|
||||||
metaTitle?: string | null;
|
|
||||||
metaDescription?: string | null;
|
|
||||||
publishedAt?: Date | string | null;
|
|
||||||
status?: ContentStatus;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BlogForm({ initialData }: Props) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const [content, setContent] = useState(initialData?.content || '');
|
|
||||||
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
|
||||||
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
|
||||||
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
|
||||||
const [publishedAtValue, setPublishedAtValue] = useState(
|
|
||||||
initialData?.publishedAt
|
|
||||||
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
|
||||||
: new Date().toISOString().slice(0, 16)
|
|
||||||
);
|
|
||||||
|
|
||||||
const isPublished = new Date(publishedAtValue) <= new Date();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getAllTags().then(setAllExistingTags);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const formData = new FormData(event.currentTarget);
|
|
||||||
const publishedAtStr = formData.get('publishedAt') as string;
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
title: formData.get('title') as string,
|
|
||||||
slug: formData.get('slug') as string,
|
|
||||||
excerpt: formData.get('excerpt') as string,
|
|
||||||
content: content,
|
|
||||||
author: formData.get('author') as string,
|
|
||||||
imageUrl: imageUrl, // Use state value
|
|
||||||
tags: tags, // Use state value
|
|
||||||
metaTitle: formData.get('metaTitle') as string,
|
|
||||||
metaDescription: formData.get('metaDescription') as string,
|
|
||||||
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
|
||||||
status: formData.get('status') as ContentStatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.imageUrl) {
|
|
||||||
toast.error("Une image est requise");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = initialData
|
|
||||||
? await updateBlogPost(initialData.id, data)
|
|
||||||
: await createBlogPost(data);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
|
|
||||||
router.push('/blog');
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Une erreur est survenue");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto pb-20">
|
|
||||||
<div className="mb-8 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
|
||||||
<ArrowLeft className="w-5 h-5" />
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-3xl font-bold text-white">
|
|
||||||
{initialData ? 'Modifier l\'article' : 'Nouvel Article'}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
|
||||||
<div className="card space-y-6">
|
|
||||||
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
|
||||||
<select
|
|
||||||
name="status"
|
|
||||||
defaultValue={initialData?.status || 'PUBLISHED'}
|
|
||||||
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
|
||||||
>
|
|
||||||
<option value="DRAFT">Brouillon</option>
|
|
||||||
<option value="PUBLISHED">Publié</option>
|
|
||||||
<option value="ARCHIVED">Archivé</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Titre</label>
|
|
||||||
<input
|
|
||||||
name="title"
|
|
||||||
defaultValue={initialData?.title}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
placeholder="Ex: L'essor de la Tech en Afrique"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Auteur</label>
|
|
||||||
<input
|
|
||||||
name="author"
|
|
||||||
defaultValue={initialData?.author}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
placeholder="Ex: Jean Dupont"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<ImageUploader
|
|
||||||
label="Image de couverture"
|
|
||||||
value={imageUrl}
|
|
||||||
onChange={setImageUrl}
|
|
||||||
name="imageUrl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">
|
|
||||||
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
|
||||||
<input
|
|
||||||
name="publishedAt"
|
|
||||||
type="datetime-local"
|
|
||||||
value={publishedAtValue}
|
|
||||||
onChange={(e) => setPublishedAtValue(e.target.value)}
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
|
|
||||||
<textarea
|
|
||||||
name="excerpt"
|
|
||||||
defaultValue={initialData?.excerpt}
|
|
||||||
required
|
|
||||||
rows={2}
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
placeholder="Un court résumé de l'article..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
|
|
||||||
<RichTextEditor
|
|
||||||
value={content}
|
|
||||||
onChange={setContent}
|
|
||||||
placeholder="Rédigez votre article ici..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SEO SECTION */}
|
|
||||||
<div className="card space-y-6">
|
|
||||||
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
|
|
||||||
<input
|
|
||||||
name="slug"
|
|
||||||
defaultValue={initialData?.slug || ''}
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
placeholder="ex: lessor-de-la-tech-afrique"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<TagInput
|
|
||||||
value={tags}
|
|
||||||
onChange={setTags}
|
|
||||||
suggestions={allExistingTags}
|
|
||||||
label="Mots-clés (tags)"
|
|
||||||
placeholder="tech, innovation, afrique..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
|
|
||||||
<input
|
|
||||||
name="metaTitle"
|
|
||||||
defaultValue={initialData?.metaTitle || ''}
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
placeholder="Titre optimisé pour les moteurs de recherche"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
|
|
||||||
<textarea
|
|
||||||
name="metaDescription"
|
|
||||||
defaultValue={initialData?.metaDescription || ''}
|
|
||||||
rows={3}
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
placeholder="Description optimisée pour les moteurs de recherche (max 160 caractères)..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
{initialData ? 'Enregistrer les modifications' : 'Créer l\'article'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useTransition } from 'react';
|
import React, { useState, useEffect, useTransition } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Save, Loader2, Globe } from 'lucide-react';
|
import { X, Save, Loader2, Globe } from 'lucide-react';
|
||||||
import { updateBusinessSeo } from '@/app/actions/business';
|
import { updateBusinessSeo } from '@/app/actions/business';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
@@ -20,8 +21,14 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
|
const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
|
||||||
const [metaDescription, setMetaDescription] = useState(business.metaDescription || '');
|
const [metaDescription, setMetaDescription] = useState(business.metaDescription || '');
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOpen || !mounted) return null;
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -39,8 +46,8 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const modalContent = (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||||
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} />
|
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
|
||||||
<div className="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
|
<div className="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
|
||||||
@@ -117,4 +124,6 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ShieldAlert, ShieldCheck } from 'lucide-react';
|
|||||||
import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
|
import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import SuspensionModal from './SuspensionModal';
|
import SuspensionModal from './SuspensionModal';
|
||||||
|
import DeleteAccountButton from './DeleteAccountButton';
|
||||||
|
|
||||||
interface ClientActionsProps {
|
interface ClientActionsProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -62,6 +63,10 @@ export default function ClientActions({ userId, userName, isSuspended }: ClientA
|
|||||||
title="Suspendre le compte"
|
title="Suspendre le compte"
|
||||||
subtitle={`Voulez-vous suspendre le compte de ${userName} ? L'utilisateur sera bloqué et recevra votre message.`}
|
subtitle={`Voulez-vous suspendre le compte de ${userName} ? L'utilisateur sera bloqué et recevra votre message.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
|
||||||
|
|
||||||
|
<DeleteAccountButton userId={userId} userName={userName} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
107
admin/src/components/CountryHistogram.tsx
Normal file
107
admin/src/components/CountryHistogram.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
admin/src/components/DeleteAccountButton.tsx
Normal file
187
admin/src/components/DeleteAccountButton.tsx
Normal 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)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
import { deleteBlogPost } from '@/app/actions/blog';
|
import { deleteBlogPost } from '@/app/actions/actualites';
|
||||||
import { Trash2, Loader2 } from 'lucide-react';
|
import { Trash2, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
export default function DeleteBlogButton({ id }: { id: string }) {
|
export default function DeleteActualitesButton({ id }: { id: string }) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
34
admin/src/components/DeleteSlideButton.tsx
Normal file
34
admin/src/components/DeleteSlideButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { deleteSlide } from '@/app/actions/slides';
|
||||||
|
import { Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function DeleteSlideButton({ id }: { id: string }) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm("Supprimer cette slide du slider d'accueil ?")) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteSlide(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Slide supprimée");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la suppression");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-2 text-slate-500 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||||
|
title="Supprimer la slide"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } from '
|
|||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import SuspensionModal from './SuspensionModal';
|
import SuspensionModal from './SuspensionModal';
|
||||||
import BusinessSeoModal from './BusinessSeoModal';
|
import BusinessSeoModal from './BusinessSeoModal';
|
||||||
|
import DeleteAccountButton from './DeleteAccountButton';
|
||||||
|
|
||||||
interface EntrepreneurActionsProps {
|
interface EntrepreneurActionsProps {
|
||||||
businessId: string;
|
businessId: string;
|
||||||
@@ -98,6 +99,10 @@ export default function EntrepreneurActions({
|
|||||||
<span className="text-[10px] uppercase font-bold">Shop</span>
|
<span className="text-[10px] uppercase font-bold">Shop</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="h-8 w-[1px] bg-slate-800 mx-1 self-center" />
|
||||||
|
|
||||||
|
<DeleteAccountButton userId={userId} userName={userName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SuspensionModal
|
<SuspensionModal
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useTransition, useState, useEffect } from 'react';
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createEvent, updateEvent } from '@/app/actions/event';
|
import { createEvent, updateEvent } from '@/app/actions/event';
|
||||||
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } from 'lucide-react';
|
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send, FileJson } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import RichTextEditor from './RichTextEditor';
|
import RichTextEditor from './RichTextEditor';
|
||||||
@@ -19,8 +19,18 @@ interface Props {
|
|||||||
export default function EventForm({ initialData }: Props) {
|
export default function EventForm({ initialData }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// JSON prefill state
|
||||||
|
const [currentData, setCurrentData] = useState<any>(initialData || {});
|
||||||
|
const [jsonString, setJsonString] = useState('');
|
||||||
|
const [showJsonInput, setShowJsonInput] = useState(false);
|
||||||
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form input states
|
||||||
const [description, setDescription] = useState(initialData?.description || '');
|
const [description, setDescription] = useState(initialData?.description || '');
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
||||||
|
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
|
||||||
|
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
|
||||||
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
const [publishedAtValue, setPublishedAtValue] = useState(
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
@@ -35,6 +45,46 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
getAllTags().then(setAllExistingTags);
|
getAllTags().then(setAllExistingTags);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleApplyJson = () => {
|
||||||
|
try {
|
||||||
|
if (!jsonString.trim()) {
|
||||||
|
throw new Error("Veuillez coller un code JSON.");
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
|
|
||||||
|
// Extract new combined tags
|
||||||
|
const newTags = Array.isArray(parsed.tags) ? [...parsed.tags] : [...tags];
|
||||||
|
if (parsed.type && !newTags.includes(parsed.type)) {
|
||||||
|
newTags.push(parsed.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update states to prefill rich components
|
||||||
|
if (parsed.description) setDescription(parsed.description);
|
||||||
|
if (parsed.thumbnailUrl) setThumbnailUrl(parsed.thumbnailUrl);
|
||||||
|
setTags(newTags);
|
||||||
|
|
||||||
|
// Update currentData to reset defaultValues on native inputs via form key remount
|
||||||
|
setCurrentData({
|
||||||
|
...currentData,
|
||||||
|
title: parsed.title || currentData.title || '',
|
||||||
|
location: parsed.location || currentData.location || '',
|
||||||
|
date: parsed.date || currentData.date || '',
|
||||||
|
link: parsed.link || parsed.registrationUrl || currentData.link || '',
|
||||||
|
slug: parsed.slug || currentData.slug || '',
|
||||||
|
metaTitle: parsed.metaTitle || currentData.metaTitle || '',
|
||||||
|
metaDescription: parsed.metaDescription || currentData.metaDescription || '',
|
||||||
|
_prefillKey: Date.now() // Force form remount
|
||||||
|
});
|
||||||
|
|
||||||
|
setJsonError(null);
|
||||||
|
setShowJsonInput(false);
|
||||||
|
setJsonString('');
|
||||||
|
toast.success("Champs de l'événement pré-remplis !");
|
||||||
|
} catch (err: any) {
|
||||||
|
setJsonError("Format JSON invalide. Vérifiez la syntaxe : " + (err.message || ''));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
@@ -53,6 +103,8 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
metaDescription: formData.get('metaDescription') as string,
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
status: formData.get('status') as ContentStatus,
|
status: formData.get('status') as ContentStatus,
|
||||||
|
coverPosition: coverPosition,
|
||||||
|
coverZoom: coverZoom,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!data.thumbnailUrl) {
|
if (!data.thumbnailUrl) {
|
||||||
@@ -67,7 +119,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(initialData ? "Événement mis à jour" : "Événement créé avec succès");
|
toast.success(initialData ? "Événement mis à jour" : "Événement créé avec succès");
|
||||||
router.push('/blog');
|
router.push('/actualites');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Une erreur est survenue");
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
@@ -75,11 +127,22 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDefaultDateStr = () => {
|
||||||
|
if (currentData.date) {
|
||||||
|
try {
|
||||||
|
return new Date(currentData.date).toISOString().slice(0, 16);
|
||||||
|
} catch(e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto pb-20">
|
<div className="max-w-4xl mx-auto pb-20">
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
<Link href="/actualites" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-white">
|
<h1 className="text-3xl font-bold text-white">
|
||||||
@@ -88,7 +151,63 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
{/* PREFILL JSON SECTION */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 mb-8 shadow-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowJsonInput(!showJsonInput)}
|
||||||
|
className="flex items-center justify-between w-full text-left font-bold text-sm text-brand-400 hover:text-brand-300 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<FileJson className="w-5 h-5 text-amber-400" />
|
||||||
|
⚡ Importer JSON (Pré-remplir les champs de l'événement automatiquement)
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-slate-800 px-2.5 py-1 rounded text-slate-400 border border-slate-700">
|
||||||
|
{showJsonInput ? 'Masquer' : 'Déplier'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showJsonInput && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-800 space-y-3 animate-in fade-in duration-200">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Collez le code JSON généré par Gemini pour remplir instantanément le titre, la date, le lieu, l'affiche, l'URL d'inscription, la description, le slug et les attributs SEO.
|
||||||
|
</p>
|
||||||
|
{jsonError && (
|
||||||
|
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 p-2.5 rounded-lg font-medium">
|
||||||
|
{jsonError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={jsonString}
|
||||||
|
onChange={(e) => setJsonString(e.target.value)}
|
||||||
|
placeholder={'{\n "title": "Afro Tech Summit 2026",\n "description": "...",\n "date": "2026-09-15T09:00:00Z",\n "location": "...",\n "thumbnailUrl": "...",\n "slug": "afro-tech-summit-2026",\n "tags": ["Conférence", "Tech", "Rwanda"],\n "metaTitle": "Afro Tech Summit 2026 - Kigali",\n "metaDescription": "Participez au sommet d\'innovation..."\n}'}
|
||||||
|
rows={8}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-xs font-mono text-emerald-400 focus:outline-none focus:border-amber-500"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setJsonString('{\n "title": "Afro Tech Summit 2026",\n "description": "<p>Le plus grand rassemblement des innovateurs de la tech africaine avec plus de 500 startups attendues.</p>",\n "date": "2026-09-15T09:00:00Z",\n "location": "Kigali Convention Centre, Rwanda",\n "thumbnailUrl": "https://images.unsplash.com/photo-1540575467063-178a50c2df87",\n "link": "https://afrotechsummit2026.com",\n "slug": "afro-tech-summit-2026",\n "tags": ["Conférence", "Tech", "Rwanda"],\n "metaTitle": "Afro Tech Summit 2026 - Kigali, Rwanda",\n "metaDescription": "Rejoignez l\'élite de la tech africaine au Kigali Convention Centre pour 3 jours d\'échanges et d\'opportunités d\'investissement."\n}');
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Exemple complet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyJson}
|
||||||
|
className="px-4 py-1.5 text-xs font-bold bg-amber-600 hover:bg-amber-700 text-white rounded-lg transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Appliquer les données
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form key={currentData._prefillKey || 'form'} onSubmit={handleSubmit} className="space-y-8">
|
||||||
<div className="card space-y-6">
|
<div className="card space-y-6">
|
||||||
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
@@ -96,7 +215,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
<select
|
<select
|
||||||
name="status"
|
name="status"
|
||||||
defaultValue={initialData?.status || 'PUBLISHED'}
|
defaultValue={currentData.status || 'PUBLISHED'}
|
||||||
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
>
|
>
|
||||||
<option value="DRAFT">Brouillon</option>
|
<option value="DRAFT">Brouillon</option>
|
||||||
@@ -111,7 +230,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Titre de l'événement</label>
|
<label className="text-sm font-medium text-slate-400">Titre de l'événement</label>
|
||||||
<input
|
<input
|
||||||
name="title"
|
name="title"
|
||||||
defaultValue={initialData?.title}
|
defaultValue={currentData.title}
|
||||||
required
|
required
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Ex: Conférence AfroHub 2026"
|
placeholder="Ex: Conférence AfroHub 2026"
|
||||||
@@ -124,7 +243,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
<input
|
<input
|
||||||
name="date"
|
name="date"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
defaultValue={initialData?.date ? new Date(initialData.date).toISOString().slice(0, 16) : ''}
|
defaultValue={getDefaultDateStr()}
|
||||||
required
|
required
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
@@ -139,7 +258,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
<MapPin className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
<MapPin className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="location"
|
name="location"
|
||||||
defaultValue={initialData?.location}
|
defaultValue={currentData.location}
|
||||||
required
|
required
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Ex: Abidjan, Côte-d'Ivoire / Zoom"
|
placeholder="Ex: Abidjan, Côte-d'Ivoire / Zoom"
|
||||||
@@ -170,6 +289,11 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
value={thumbnailUrl}
|
value={thumbnailUrl}
|
||||||
onChange={setThumbnailUrl}
|
onChange={setThumbnailUrl}
|
||||||
name="thumbnailUrl"
|
name="thumbnailUrl"
|
||||||
|
showPositionControls={true}
|
||||||
|
position={coverPosition}
|
||||||
|
zoom={coverZoom}
|
||||||
|
onPositionChange={setCoverPosition}
|
||||||
|
onZoomChange={setCoverZoom}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -178,7 +302,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="link"
|
name="link"
|
||||||
defaultValue={initialData?.link}
|
defaultValue={currentData.link}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
@@ -205,7 +329,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
|
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
|
||||||
<input
|
<input
|
||||||
name="slug"
|
name="slug"
|
||||||
defaultValue={initialData?.slug || ''}
|
defaultValue={currentData.slug || ''}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="ex: conference-afrohub-2026"
|
placeholder="ex: conference-afrohub-2026"
|
||||||
/>
|
/>
|
||||||
@@ -226,7 +350,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
|
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
|
||||||
<input
|
<input
|
||||||
name="metaTitle"
|
name="metaTitle"
|
||||||
defaultValue={initialData?.metaTitle || ''}
|
defaultValue={currentData.metaTitle || ''}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Titre optimisé pour Google"
|
placeholder="Titre optimisé pour Google"
|
||||||
/>
|
/>
|
||||||
@@ -236,7 +360,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
|
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="metaDescription"
|
name="metaDescription"
|
||||||
defaultValue={initialData?.metaDescription || ''}
|
defaultValue={currentData.metaDescription || ''}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Description courte pour les résultats de recherche..."
|
placeholder="Description courte pour les résultats de recherche..."
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useEffect, useTransition } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business';
|
import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business';
|
||||||
import { Star, X, Loader2, Save } from 'lucide-react';
|
import { Star, X, Loader2, Save } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
@@ -19,6 +20,12 @@ interface Props {
|
|||||||
export default function FeaturedModal({ business }: Props) {
|
export default function FeaturedModal({ business }: Props) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -54,94 +61,97 @@ export default function FeaturedModal({ business }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div className="modal-overlay" style={{ zIndex: 9999 }}>
|
||||||
|
<div className="modal-content max-w-md shadow-2xl">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="absolute top-4 right-4 text-slate-500 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">💰 Mise en avant / Afroshine</h2>
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
Activez la présence dans la section <strong>"Entreprises à la une"</strong> sur l'accueil. Vous pouvez aussi renseigner les détails optionnels pour l'afficher en <strong>Tête d'affiche (Afroshine)</strong> dans l'annuaire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleToggleFeature} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Nom du fondateur (Optionnel)</label>
|
||||||
|
<input
|
||||||
|
name="founderName"
|
||||||
|
defaultValue={business.founderName || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="Ex: Aliko Dangote"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur (Optionnel)</label>
|
||||||
|
<input
|
||||||
|
name="founderImageUrl"
|
||||||
|
defaultValue={business.founderImageUrl || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Métrique Clé (Optionnel, ex: Impact)</label>
|
||||||
|
<input
|
||||||
|
name="keyMetric"
|
||||||
|
defaultValue={business.keyMetric || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="Ex: +500 emplois créés"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||||
|
Définir comme Afroshine
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{business.isFeatured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
|
||||||
|
>
|
||||||
|
Révoquer le titre
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-all flex items-center gap-2 mx-auto ${
|
className={`px-2.5 py-1 rounded-md border text-xs font-medium transition-all flex items-center gap-1.5 mx-auto whitespace-nowrap w-fit ${
|
||||||
business.isFeatured
|
business.isFeatured
|
||||||
? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20'
|
? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20'
|
||||||
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
|
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
|
title="Configurer la mise en avant Afroshine (Annuaire)"
|
||||||
>
|
>
|
||||||
<Star className={`w-4 h-4 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
|
<Star className={`w-3.5 h-3.5 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
|
||||||
{business.isFeatured ? 'Vedette Active' : 'Mettre en avant'}
|
{business.isFeatured ? 'Actif' : 'Activer'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{mounted && isOpen && createPortal(modalContent, document.body)}
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal-content">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className="absolute top-4 right-4 text-slate-500 hover:text-white"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">💰 Afroshine</h2>
|
|
||||||
<p className="text-slate-400">Configurez les détails pour {business.name}.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleToggleFeature} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Nom du fondateur</label>
|
|
||||||
<input
|
|
||||||
name="founderName"
|
|
||||||
defaultValue={business.founderName || ''}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
|
||||||
placeholder="Ex: Aliko Dangote"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur</label>
|
|
||||||
<input
|
|
||||||
name="founderImageUrl"
|
|
||||||
defaultValue={business.founderImageUrl || ''}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Métrique Clé (ex: Chiffre d'affaires, Impact)</label>
|
|
||||||
<input
|
|
||||||
name="keyMetric"
|
|
||||||
defaultValue={business.keyMetric || ''}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
|
||||||
placeholder="Ex: +500 emplois créés"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 flex flex-col gap-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
|
|
||||||
>
|
|
||||||
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
|
||||||
Définir comme Afroshine
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{business.isFeatured && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRemove}
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
|
|
||||||
>
|
|
||||||
Révoquer le titre
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,25 @@ interface Props {
|
|||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
showPositionControls?: boolean;
|
||||||
|
position?: string;
|
||||||
|
zoom?: number;
|
||||||
|
onPositionChange?: (value: string) => void;
|
||||||
|
onZoomChange?: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageUploader({ value, onChange, label, placeholder, name }: Props) {
|
export default function ImageUploader({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
name,
|
||||||
|
showPositionControls = false,
|
||||||
|
position = "50% 50%",
|
||||||
|
zoom = 1,
|
||||||
|
onPositionChange,
|
||||||
|
onZoomChange
|
||||||
|
}: Props) {
|
||||||
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
|
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -23,8 +39,9 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if (!file.type.startsWith('image/')) {
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/pjpeg'];
|
||||||
toast.error("Le fichier doit être une image");
|
if (!allowedTypes.includes(file.type) && !file.type.startsWith('image/')) {
|
||||||
|
toast.error("Le fichier doit être une image (JPG, PNG ou WEBP)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +109,7 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name={name}
|
name={name}
|
||||||
value={value}
|
value={value || ""}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder || "https://images.unsplash.com/..."}
|
placeholder={placeholder || "https://images.unsplash.com/..."}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
@@ -120,37 +137,111 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
|
<div className="space-y-4">
|
||||||
<img
|
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
|
||||||
src={value}
|
<img
|
||||||
alt="Prévisualisation"
|
src={value}
|
||||||
className="w-full h-full object-cover"
|
alt="Prévisualisation"
|
||||||
/>
|
className="w-full h-full object-cover"
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
style={{
|
||||||
<button
|
objectPosition: position,
|
||||||
type="button"
|
transform: `scale(${zoom})`,
|
||||||
onClick={() => fileInputRef.current?.click()}
|
transformOrigin: position
|
||||||
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
|
}}
|
||||||
>
|
/>
|
||||||
<Upload className="w-5 h-5" />
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
</button>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => fileInputRef.current?.click()}
|
||||||
onClick={clearImage}
|
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
|
||||||
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
|
>
|
||||||
>
|
<Upload className="w-5 h-5" />
|
||||||
<X className="w-5 h-5" />
|
</button>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearImage}
|
||||||
|
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Hidden inputs for values to be sent via form if needed */}
|
||||||
|
<input type="hidden" name={name} value={value || ""} />
|
||||||
|
{showPositionControls && (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name={`${name}Position`} value={position} />
|
||||||
|
<input type="hidden" name={`${name}Zoom`} value={zoom.toString()} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Hidden input for value to be sent via form if needed */}
|
|
||||||
<input type="hidden" name={name} value={value} />
|
{showPositionControls && (
|
||||||
|
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-800 space-y-4 animate-in fade-in duration-300">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<label className="text-[10px] font-bold text-slate-500 uppercase">Zoom : {zoom.toFixed(1)}x</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onZoomChange?.(1)}
|
||||||
|
className="text-[10px] text-indigo-400 hover:text-white"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => onZoomChange?.(parseFloat(e.target.value))}
|
||||||
|
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-slate-500 uppercase">Position X : {position.split(' ')[0]}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={parseInt(position.split(' ')[0])}
|
||||||
|
onChange={(e) => {
|
||||||
|
const y = position.split(' ')[1];
|
||||||
|
onPositionChange?.(`${e.target.value}% ${y}`);
|
||||||
|
}}
|
||||||
|
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-slate-500 uppercase">Position Y : {position.split(' ')[1]}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={parseInt(position.split(' ')[1])}
|
||||||
|
onChange={(e) => {
|
||||||
|
const x = position.split(' ')[0];
|
||||||
|
onPositionChange?.(`${x} ${e.target.value}%`);
|
||||||
|
}}
|
||||||
|
className="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
accept="image/*"
|
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
393
admin/src/components/ImportJsonForm.tsx
Normal file
393
admin/src/components/ImportJsonForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useTransition, useState, useEffect } from 'react';
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createInterview, updateInterview } from '@/app/actions/interview';
|
import { createInterview, updateInterview } from '@/app/actions/interview';
|
||||||
import { Loader2, ArrowLeft, Save, Video, FileText, Calendar } from 'lucide-react';
|
import { Loader2, ArrowLeft, Save, Video, FileText, Calendar, FileJson } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { InterviewType, ContentStatus } from '@prisma/client';
|
import { InterviewType, ContentStatus } from '@prisma/client';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
@@ -19,6 +19,14 @@ interface Props {
|
|||||||
export default function InterviewForm({ initialData }: Props) {
|
export default function InterviewForm({ initialData }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// JSON prefill state
|
||||||
|
const [currentData, setCurrentData] = useState<any>(initialData || {});
|
||||||
|
const [jsonString, setJsonString] = useState('');
|
||||||
|
const [showJsonInput, setShowJsonInput] = useState(false);
|
||||||
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form input states
|
||||||
const [content, setContent] = useState(initialData?.content || '');
|
const [content, setContent] = useState(initialData?.content || '');
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
||||||
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
@@ -35,6 +43,50 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
getAllTags().then(setAllExistingTags);
|
getAllTags().then(setAllExistingTags);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleApplyJson = () => {
|
||||||
|
try {
|
||||||
|
if (!jsonString.trim()) {
|
||||||
|
throw new Error("Veuillez coller un code JSON.");
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
|
|
||||||
|
// Extract new combined tags
|
||||||
|
const newTags = Array.isArray(parsed.tags) ? [...parsed.tags] : [...tags];
|
||||||
|
if (parsed.typeTag && !newTags.includes(parsed.typeTag)) {
|
||||||
|
newTags.push(parsed.typeTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update states to prefill rich components
|
||||||
|
if (parsed.content) setContent(parsed.content);
|
||||||
|
if (parsed.thumbnailUrl) setThumbnailUrl(parsed.thumbnailUrl);
|
||||||
|
setTags(newTags);
|
||||||
|
|
||||||
|
// Update currentData to reset defaultValues on native inputs via form key remount
|
||||||
|
setCurrentData({
|
||||||
|
...currentData,
|
||||||
|
title: parsed.title || currentData.title || '',
|
||||||
|
guestName: parsed.guestName || currentData.guestName || '',
|
||||||
|
companyName: parsed.companyName || currentData.companyName || '',
|
||||||
|
role: parsed.role || currentData.role || '',
|
||||||
|
type: parsed.type || currentData.type || 'TEXT',
|
||||||
|
videoUrl: parsed.videoUrl || currentData.videoUrl || '',
|
||||||
|
duration: parsed.duration || currentData.duration || '',
|
||||||
|
excerpt: parsed.excerpt || currentData.excerpt || '',
|
||||||
|
slug: parsed.slug || currentData.slug || '',
|
||||||
|
metaTitle: parsed.metaTitle || currentData.metaTitle || '',
|
||||||
|
metaDescription: parsed.metaDescription || currentData.metaDescription || '',
|
||||||
|
_prefillKey: Date.now() // Force form remount
|
||||||
|
});
|
||||||
|
|
||||||
|
setJsonError(null);
|
||||||
|
setShowJsonInput(false);
|
||||||
|
setJsonString('');
|
||||||
|
toast.success("Champs d'interview pré-remplis !");
|
||||||
|
} catch (err: any) {
|
||||||
|
setJsonError("Format JSON invalide. Vérifiez la syntaxe : " + (err.message || ''));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
@@ -71,7 +123,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(initialData ? "Interview mise à jour" : "Interview créée avec succès");
|
toast.success(initialData ? "Interview mise à jour" : "Interview créée avec succès");
|
||||||
router.push('/blog');
|
router.push('/actualites');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Une erreur est survenue");
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
@@ -83,7 +135,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<div className="max-w-4xl mx-auto pb-20">
|
<div className="max-w-4xl mx-auto pb-20">
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
<Link href="/actualites" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-white">
|
<h1 className="text-3xl font-bold text-white">
|
||||||
@@ -92,7 +144,63 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
{/* PREFILL JSON SECTION */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 mb-8 shadow-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowJsonInput(!showJsonInput)}
|
||||||
|
className="flex items-center justify-between w-full text-left font-bold text-sm text-brand-400 hover:text-brand-300 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<FileJson className="w-5 h-5 text-emerald-400" />
|
||||||
|
⚡ Importer JSON (Pré-remplir les champs d'interview automatiquement)
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-slate-800 px-2.5 py-1 rounded text-slate-400 border border-slate-700">
|
||||||
|
{showJsonInput ? 'Masquer' : 'Déplier'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showJsonInput && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-800 space-y-3 animate-in fade-in duration-200">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Collez le code JSON généré par Gemini pour remplir instantanément les infos de l'invité, l'extrait, la miniature, la transcription, le slug et les balises SEO.
|
||||||
|
</p>
|
||||||
|
{jsonError && (
|
||||||
|
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 p-2.5 rounded-lg font-medium">
|
||||||
|
{jsonError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={jsonString}
|
||||||
|
onChange={(e) => setJsonString(e.target.value)}
|
||||||
|
placeholder={'{\n "title": "Comment nous avons levé 2M€...",\n "guestName": "Aminata Diallo",\n "companyName": "GoAfrica Logistique",\n "role": "CEO & Fondatrice",\n "excerpt": "Entretien exclusif...",\n "thumbnailUrl": "...",\n "slug": "interview-aminata-diallo-goafrica",\n "tags": ["Logistique", "Interview", "Sénégal"],\n "metaTitle": "Interview Aminata Diallo - GoAfrica Logistique",\n "metaDescription": "Découvrez le parcours inspirant d\'Aminata Diallo..."\n}'}
|
||||||
|
rows={8}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-xs font-mono text-emerald-400 focus:outline-none focus:border-emerald-500"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setJsonString('{\n "title": "Comment nous avons levé 2M€ pour révolutionner le transport",\n "guestName": "Aminata Diallo",\n "companyName": "GoAfrica Logistique",\n "role": "CEO & Fondatrice",\n "type": "TEXT",\n "excerpt": "Entretien exclusif avec Aminata Diallo sur les défis de la chaîne d\'approvisionnement en Afrique de l\'Ouest.",\n "content": "<p><strong>Afrohub : Qu\'est-ce qui vous a poussée à lancer GoAfrica ?</strong></p><p>Aminata : Le constat était simple...</p>",\n "thumbnailUrl": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=1200",\n "slug": "interview-aminata-diallo-goafrica",\n "tags": ["Logistique", "Interview", "Sénégal"],\n "metaTitle": "Interview Aminata Diallo - GoAfrica Logistique",\n "metaDescription": "Découvrez les secrets de la levée de fonds de 2M€ par GoAfrica Logistique dans cet entretien exclusif avec sa fondatrice."\n}');
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Exemple complet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyJson}
|
||||||
|
className="px-4 py-1.5 text-xs font-bold bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Appliquer les données
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form key={currentData._prefillKey || 'form'} onSubmit={handleSubmit} className="space-y-8">
|
||||||
<div className="card space-y-6">
|
<div className="card space-y-6">
|
||||||
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
@@ -100,7 +208,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
<select
|
<select
|
||||||
name="status"
|
name="status"
|
||||||
defaultValue={initialData?.status || 'PUBLISHED'}
|
defaultValue={currentData.status || 'PUBLISHED'}
|
||||||
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
>
|
>
|
||||||
<option value="DRAFT">Brouillon</option>
|
<option value="DRAFT">Brouillon</option>
|
||||||
@@ -115,7 +223,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Titre de l'interview</label>
|
<label className="text-sm font-medium text-slate-400">Titre de l'interview</label>
|
||||||
<input
|
<input
|
||||||
name="title"
|
name="title"
|
||||||
defaultValue={initialData?.title}
|
defaultValue={currentData.title}
|
||||||
required
|
required
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Ex: Entreprise X : Une success story..."
|
placeholder="Ex: Entreprise X : Une success story..."
|
||||||
@@ -125,7 +233,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Nom de l'invité</label>
|
<label className="text-sm font-medium text-slate-400">Nom de l'invité</label>
|
||||||
<input
|
<input
|
||||||
name="guestName"
|
name="guestName"
|
||||||
defaultValue={initialData?.guestName}
|
defaultValue={currentData.guestName}
|
||||||
required
|
required
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Ex: Sarah Koné"
|
placeholder="Ex: Sarah Koné"
|
||||||
@@ -135,7 +243,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Entreprise</label>
|
<label className="text-sm font-medium text-slate-400">Entreprise</label>
|
||||||
<input
|
<input
|
||||||
name="companyName"
|
name="companyName"
|
||||||
defaultValue={initialData?.companyName}
|
defaultValue={currentData.companyName}
|
||||||
required
|
required
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Ex: TechAfrica"
|
placeholder="Ex: TechAfrica"
|
||||||
@@ -145,7 +253,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
|
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
|
||||||
<input
|
<input
|
||||||
name="role"
|
name="role"
|
||||||
defaultValue={initialData?.role}
|
defaultValue={currentData.role}
|
||||||
required
|
required
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Ex: Fondatrice & CEO"
|
placeholder="Ex: Fondatrice & CEO"
|
||||||
@@ -158,11 +266,11 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Type d'interview</label>
|
<label className="text-sm font-medium text-slate-400">Type d'interview</label>
|
||||||
<select
|
<select
|
||||||
name="type"
|
name="type"
|
||||||
defaultValue={initialData?.type || 'VIDEO'}
|
defaultValue={currentData.type || 'VIDEO'}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
<option value="VIDEO">Vidéo</option>
|
<option value="VIDEO">Vidéo</option>
|
||||||
<option value="ARTICLE">Article (Texte)</option>
|
<option value="TEXT">Article (Texte)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -195,7 +303,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label>
|
<label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label>
|
||||||
<input
|
<input
|
||||||
name="videoUrl"
|
name="videoUrl"
|
||||||
defaultValue={initialData?.videoUrl}
|
defaultValue={currentData.videoUrl}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="https://youtube.com/..."
|
placeholder="https://youtube.com/..."
|
||||||
/>
|
/>
|
||||||
@@ -203,7 +311,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
|
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
|
||||||
<input
|
<input
|
||||||
name="duration"
|
name="duration"
|
||||||
defaultValue={initialData?.duration}
|
defaultValue={currentData.duration}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Ex: 12 min"
|
placeholder="Ex: 12 min"
|
||||||
/>
|
/>
|
||||||
@@ -215,7 +323,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label>
|
<label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="excerpt"
|
name="excerpt"
|
||||||
defaultValue={initialData?.excerpt}
|
defaultValue={currentData.excerpt}
|
||||||
required
|
required
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
@@ -241,7 +349,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
|
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
|
||||||
<input
|
<input
|
||||||
name="slug"
|
name="slug"
|
||||||
defaultValue={initialData?.slug || ''}
|
defaultValue={currentData.slug || ''}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="ex: interview-sarah-kone-techafrica"
|
placeholder="ex: interview-sarah-kone-techafrica"
|
||||||
/>
|
/>
|
||||||
@@ -262,7 +370,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
|
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
|
||||||
<input
|
<input
|
||||||
name="metaTitle"
|
name="metaTitle"
|
||||||
defaultValue={initialData?.metaTitle || ''}
|
defaultValue={currentData.metaTitle || ''}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Titre optimisé SEO"
|
placeholder="Titre optimisé SEO"
|
||||||
/>
|
/>
|
||||||
@@ -272,7 +380,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
|
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="metaDescription"
|
name="metaDescription"
|
||||||
defaultValue={initialData?.metaDescription || ''}
|
defaultValue={currentData.metaDescription || ''}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Description courte SEO..."
|
placeholder="Description courte SEO..."
|
||||||
|
|||||||
183
admin/src/components/OneShotPricingSettings.tsx
Normal file
183
admin/src/components/OneShotPricingSettings.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useTransition } from 'react';
|
||||||
|
import { Save, Loader2, Zap, Calendar, Newspaper, Layout } from 'lucide-react';
|
||||||
|
import { updateOneShotPlan } from '@/app/actions/one-shot';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { OneShotType } from '@prisma/client';
|
||||||
|
|
||||||
|
interface OneShotPlan {
|
||||||
|
id: string;
|
||||||
|
type: OneShotType;
|
||||||
|
name: string;
|
||||||
|
priceXOF: number;
|
||||||
|
priceEUR: number;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS = {
|
||||||
|
BUMP: Zap,
|
||||||
|
POST_EVENT: Calendar,
|
||||||
|
POST_NEWS: Newspaper,
|
||||||
|
AD_DIRECTORY: Layout,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
BUMP: "Remonter dans l'annuaire",
|
||||||
|
POST_EVENT: "Poster un événement",
|
||||||
|
POST_NEWS: "Poster une actualité",
|
||||||
|
AD_DIRECTORY: "Publicité dans l'annuaire",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OneShotPricingSettings({ initialPlans }: { initialPlans: OneShotPlan[] }) {
|
||||||
|
const [plans, setPlans] = useState<OneShotPlan[]>(initialPlans);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Ensure all types are present even if not in DB yet
|
||||||
|
const allTypes: OneShotType[] = ['BUMP', 'POST_EVENT', 'POST_NEWS', 'AD_DIRECTORY'];
|
||||||
|
|
||||||
|
const getPlanByType = (type: OneShotType) => {
|
||||||
|
return plans.find(p => p.type === type) || {
|
||||||
|
id: '',
|
||||||
|
type,
|
||||||
|
name: TYPE_LABELS[type],
|
||||||
|
priceXOF: 0,
|
||||||
|
priceEUR: 0,
|
||||||
|
description: ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (type: OneShotType, data: any) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateOneShotPlan(type, data);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Plan ${TYPE_LABELS[type]} mis à jour`);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la mise à jour");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-yellow-400" />
|
||||||
|
<h2 className="font-semibold text-white">Tarifs des Services Ponctuels (One-Shot)</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (confirm("Initialiser tous les plans avec les valeurs par défaut ?")) {
|
||||||
|
startTransition(async () => {
|
||||||
|
for (const type of allTypes) {
|
||||||
|
await updateOneShotPlan(type, {
|
||||||
|
name: TYPE_LABELS[type],
|
||||||
|
priceXOF: 5000,
|
||||||
|
priceEUR: 8,
|
||||||
|
description: `Service de ${TYPE_LABELS[type].toLowerCase()}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success("Plans initialisés. Rechargement...");
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs bg-slate-800 hover:bg-slate-700 text-slate-300 px-3 py-1 rounded-lg border border-slate-700 transition-all"
|
||||||
|
>
|
||||||
|
Initialiser par défaut
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Configurez les prix des services à l'acte. Ces tarifs seront affichés sur la page de tarification.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{allTypes.map((type) => {
|
||||||
|
const plan = getPlanByType(type);
|
||||||
|
const Icon = TYPE_ICONS[type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={type} className="bg-slate-950/50 border border-slate-800 rounded-xl p-6 hover:border-slate-700 transition-colors">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center">
|
||||||
|
<Icon className="w-6 h-6 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white text-lg">{TYPE_LABELS[type]}</h3>
|
||||||
|
<p className="text-xs text-slate-500 font-mono">{type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
handleUpdate(type, {
|
||||||
|
name: formData.get('name'),
|
||||||
|
priceXOF: parseInt(formData.get('priceXOF') as string || '0'),
|
||||||
|
priceEUR: parseInt(formData.get('priceEUR') as string || '0'),
|
||||||
|
description: formData.get('description'),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-slate-500 uppercase">Nom affiché</label>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
defaultValue={plan.name}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-slate-500 uppercase">Prix (XOF)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="priceXOF"
|
||||||
|
defaultValue={plan.priceXOF}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-slate-500 uppercase">Prix (EUR)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="priceEUR"
|
||||||
|
defaultValue={plan.priceEUR}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-800 text-white p-2 rounded-lg font-bold text-sm transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-4 space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-slate-500 uppercase">Description courte</label>
|
||||||
|
<input
|
||||||
|
name="description"
|
||||||
|
defaultValue={plan.description || ''}
|
||||||
|
placeholder="Ex: Remontez votre entreprise en tête de liste pour 7 jours"
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,12 +34,13 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
|
|||||||
const quillRef = useRef<any>(null);
|
const quillRef = useRef<any>(null);
|
||||||
|
|
||||||
const modules = useMemo(() => ({
|
const modules = useMemo(() => ({
|
||||||
|
table: true,
|
||||||
toolbar: {
|
toolbar: {
|
||||||
container: [
|
container: [
|
||||||
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
['divider', 'link', 'clean'],
|
['table', 'divider', 'link', 'clean'],
|
||||||
],
|
],
|
||||||
handlers: {
|
handlers: {
|
||||||
divider: function() {
|
divider: function() {
|
||||||
@@ -58,7 +59,7 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
|
|||||||
'header',
|
'header',
|
||||||
'bold', 'italic', 'underline', 'strike',
|
'bold', 'italic', 'underline', 'strike',
|
||||||
'list',
|
'list',
|
||||||
'divider', 'link',
|
'divider', 'link', 'table'
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
FileText,
|
FileText,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Tags
|
Tags,
|
||||||
|
Image as ImageIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
@@ -25,10 +26,11 @@ const menuItems = [
|
|||||||
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
||||||
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
||||||
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
||||||
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
{ name: 'Actualités & Interviews', icon: BookOpen, href: '/actualites' },
|
||||||
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
|
{ name: 'Forfaits & Services', icon: CreditCard, href: '/plans' },
|
||||||
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
|
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
|
||||||
{ name: 'Pays', icon: Globe, href: '/countries' },
|
{ name: 'Pays', icon: Globe, href: '/countries' },
|
||||||
|
{ name: 'Bannière', icon: ImageIcon, href: '/slides' },
|
||||||
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
||||||
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
||||||
];
|
];
|
||||||
@@ -45,7 +47,7 @@ export default function Sidebar() {
|
|||||||
}, [pathname]); // Refresh on navigation
|
}, [pathname]); // Refresh on navigation
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-sidebar p-6 flex flex-col">
|
<div className="admin-sidebar z-50 p-6 flex flex-col">
|
||||||
<div className="flex items-center gap-3 mb-10 px-2">
|
<div className="flex items-center gap-3 mb-10 px-2">
|
||||||
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
|
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
|
||||||
<ShieldCheck className="text-white" />
|
<ShieldCheck className="text-white" />
|
||||||
|
|||||||
301
admin/src/components/SlideForm.tsx
Normal file
301
admin/src/components/SlideForm.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createSlide, updateSlide, searchBusinesses } from '@/app/actions/slides';
|
||||||
|
import { Loader2, ArrowLeft, Save, Search, Check, X, ImageIcon, Briefcase } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SlideForm({ initialData }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [type, setType] = useState(initialData?.type || 'CUSTOM');
|
||||||
|
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
||||||
|
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
|
||||||
|
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
|
||||||
|
const [isActive, setIsActive] = useState(initialData?.isActive ?? true);
|
||||||
|
|
||||||
|
// Business search state
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
const [selectedBusiness, setSelectedBusiness] = useState<any>(initialData?.business || null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchQuery.length > 1) {
|
||||||
|
const delayDebounceFn = setTimeout(() => {
|
||||||
|
setIsSearching(true);
|
||||||
|
searchBusinesses(searchQuery).then(results => {
|
||||||
|
setSearchResults(results);
|
||||||
|
setIsSearching(false);
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(delayDebounceFn);
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
type,
|
||||||
|
businessId: type === 'BUSINESS' ? selectedBusiness?.id : null,
|
||||||
|
title: formData.get('title') as string,
|
||||||
|
subtitle: formData.get('subtitle') as string,
|
||||||
|
imageUrl: type === 'CUSTOM' ? imageUrl : (selectedBusiness?.coverUrl || selectedBusiness?.logoUrl || ''),
|
||||||
|
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
|
||||||
|
order: parseInt(formData.get('order') as string) || 0,
|
||||||
|
isActive,
|
||||||
|
coverPosition: coverPosition,
|
||||||
|
coverZoom: coverZoom,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'BUSINESS' && !data.businessId) {
|
||||||
|
toast.error("Veuillez sélectionner une entreprise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'CUSTOM' && !data.imageUrl) {
|
||||||
|
toast.error("Une image est requise pour une slide personnalisée");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = initialData
|
||||||
|
? await updateSlide(initialData.id, data)
|
||||||
|
: await createSlide(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(initialData ? "Slide mise à jour" : "Slide créée avec succès");
|
||||||
|
router.push('/slides');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto pb-20">
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/slides" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{initialData ? 'Modifier la Slide' : 'Nouvelle Slide'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Type de Slide</h2>
|
||||||
|
<div className="flex bg-slate-900 p-1 rounded-lg border border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setType('BUSINESS')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all ${
|
||||||
|
type === 'BUSINESS' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Briefcase className="w-4 h-4" />
|
||||||
|
Entreprise
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setType('CUSTOM')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all ${
|
||||||
|
type === 'CUSTOM' ? 'bg-emerald-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
Personnalisé
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === 'BUSINESS' ? (
|
||||||
|
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Rechercher une entreprise</label>
|
||||||
|
|
||||||
|
{selectedBusiness ? (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-indigo-500/10 border border-indigo-500/20 rounded-xl">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img src={selectedBusiness.logoUrl} className="w-12 h-12 rounded-lg object-cover" alt="" />
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-bold">{selectedBusiness.name}</div>
|
||||||
|
<div className="text-xs text-indigo-400">{selectedBusiness.location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedBusiness(null)}
|
||||||
|
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
key="search-query-input"
|
||||||
|
type="text"
|
||||||
|
value={searchQuery || ""}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Tapez le nom d'une entreprise..."
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSearching && (
|
||||||
|
<div className="absolute right-3 top-3">
|
||||||
|
<Loader2 className="w-5 h-5 text-indigo-500 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="absolute z-10 w-full mt-2 bg-slate-800 border border-slate-700 rounded-xl shadow-2xl overflow-hidden max-h-60 overflow-y-auto">
|
||||||
|
{searchResults.map((biz) => (
|
||||||
|
<button
|
||||||
|
key={biz.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBusiness(biz);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 p-3 hover:bg-slate-700 text-left transition-colors border-b border-slate-700 last:border-0"
|
||||||
|
>
|
||||||
|
<img src={biz.logoUrl} className="w-10 h-10 rounded object-cover" alt="" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">{biz.name}</div>
|
||||||
|
<div className="text-xs text-slate-400">{biz.location}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-900/50 rounded-lg border border-slate-800 italic text-xs text-slate-500">
|
||||||
|
L'image, le titre et le lien seront automatiquement récupérés depuis la fiche de l'entreprise.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Titre</label>
|
||||||
|
<input
|
||||||
|
key="custom-title-input"
|
||||||
|
name="title"
|
||||||
|
defaultValue={initialData?.title || ""}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
placeholder="Ex: Découvrez l'artisanat local"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
|
||||||
|
<input
|
||||||
|
key="custom-subtitle-input"
|
||||||
|
name="subtitle"
|
||||||
|
defaultValue={initialData?.subtitle || ""}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
placeholder="Ex: Des produits uniques faits main"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Lien (URL)</label>
|
||||||
|
<input
|
||||||
|
key="custom-link-input"
|
||||||
|
name="linkUrl"
|
||||||
|
defaultValue={initialData?.linkUrl || ""}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
placeholder="Ex: /annuaire ou https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageUploader
|
||||||
|
label="Image de fond"
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={setImageUrl}
|
||||||
|
name="imageUrl"
|
||||||
|
showPositionControls={true}
|
||||||
|
position={coverPosition}
|
||||||
|
zoom={coverZoom}
|
||||||
|
onPositionChange={setCoverPosition}
|
||||||
|
onZoomChange={setCoverZoom}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Paramètres d'Affichage</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Ordre d'affichage</label>
|
||||||
|
<input
|
||||||
|
key="order-input"
|
||||||
|
name="order"
|
||||||
|
type="number"
|
||||||
|
defaultValue={initialData?.order ?? 0}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">Les slides sont triées par ordre croissant.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-900 rounded-xl border border-slate-800">
|
||||||
|
<span className="text-sm font-medium text-slate-300">Statut de la slide</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsActive(!isActive)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
|
||||||
|
isActive ? 'bg-emerald-600' : 'bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${
|
||||||
|
isActive ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
{initialData ? 'Enregistrer les modifications' : 'Créer la slide'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
admin/src/components/ToggleHomeFeatureButton.tsx
Normal file
46
admin/src/components/ToggleHomeFeatureButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
admin/src/components/ToggleSlideStatusButton.tsx
Normal file
42
admin/src/components/ToggleSlideStatusButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { toggleSlideStatus } from '@/app/actions/slides';
|
||||||
|
import { Power, PowerOff, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function ToggleSlideStatusButton({ id, currentStatus }: { id: string, currentStatus: boolean }) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await toggleSlideStatus(id, currentStatus);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(currentStatus ? "Slide désactivée" : "Slide activée");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la mise à jour");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={isPending}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
currentStatus
|
||||||
|
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/20'
|
||||||
|
: 'bg-slate-500/10 text-slate-400 border border-slate-500/20 hover:bg-slate-500/20'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : currentStatus ? (
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<PowerOff className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{currentStatus ? 'ACTIVE' : 'INACTIVE'}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
admin/src/lib/mail.ts
Normal file
38
admin/src/lib/mail.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { PrismaPg } from '@prisma/adapter-pg'
|
|
||||||
// Updated: 2026-04-18 14:28 (Forced reload for PricingPlan model)
|
|
||||||
import pg from 'pg'
|
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL!
|
const connectionString = process.env.DATABASE_URL || "postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?schema=public"
|
||||||
|
|
||||||
function makePrisma() {
|
const globalForPrisma = globalThis as unknown as { prisma_admin_native?: PrismaClient }
|
||||||
const pool = new pg.Pool({ connectionString })
|
|
||||||
const adapter = new PrismaPg(pool)
|
|
||||||
return new PrismaClient({ adapter })
|
|
||||||
}
|
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as { prisma_v3: PrismaClient }
|
export const prisma = globalForPrisma.prisma_admin_native || new PrismaClient({
|
||||||
|
datasources: {
|
||||||
|
db: {
|
||||||
|
url: connectionString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma_v3 || makePrisma()
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_admin_native = prisma
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v3 = prisma
|
|
||||||
|
|
||||||
export default prisma
|
export default prisma
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ import { generateSlug } from '@/lib/utils';
|
|||||||
import { Business, BlogPost } from '@/types';
|
import { Business, BlogPost } from '@/types';
|
||||||
import BusinessCard from '@/components/BusinessCard';
|
import BusinessCard from '@/components/BusinessCard';
|
||||||
import { useUser } from '@/components/UserProvider';
|
import { useUser } from '@/components/UserProvider';
|
||||||
|
import HomeSlider from '@/components/HomeSlider';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialFeatured: Business[];
|
initialFeatured: Business[];
|
||||||
initialPosts: BlogPost[];
|
initialPosts: BlogPost[];
|
||||||
initialCategories: any[];
|
initialCategories: any[];
|
||||||
|
initialSlides: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props) => {
|
const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialSlides }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, settings } = useUser();
|
const { user, settings } = useUser();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -23,11 +25,11 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
|||||||
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
|
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
|
||||||
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
||||||
const [categories, setCategories] = useState<any[]>(initialCategories);
|
const [categories, setCategories] = useState<any[]>(initialCategories);
|
||||||
|
const [slides, setSlides] = useState<any[]>(initialSlides);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingPosts, setLoadingPosts] = useState(false);
|
const [loadingPosts, setLoadingPosts] = useState(false);
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (searchTerm: string, locationTerm: string) => {
|
||||||
e.preventDefault();
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (searchTerm) params.append('q', searchTerm);
|
if (searchTerm) params.append('q', searchTerm);
|
||||||
if (locationTerm) params.append('location', locationTerm);
|
if (locationTerm) params.append('location', locationTerm);
|
||||||
@@ -36,47 +38,8 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Hero Section */}
|
{/* Dynamic Hero Slider */}
|
||||||
<div className="relative bg-dark-900 overflow-hidden">
|
<HomeSlider slides={slides} onSearch={handleSearch} />
|
||||||
<div className="absolute inset-0 opacity-40">
|
|
||||||
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" width={1740} height={1160} />
|
|
||||||
</div>
|
|
||||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
|
|
||||||
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
|
|
||||||
Boostez votre visibilité dans <br />
|
|
||||||
<span className="text-brand-500">l'écosystème africain</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
|
|
||||||
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="max-w-3xl bg-white p-2 rounded-lg shadow-xl flex flex-col md:flex-row gap-2">
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<Search className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Que recherchez-vous ? (ex: Développeur, Traiteur...)"
|
|
||||||
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
|
|
||||||
<MapPin className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Localisation (ex: Abidjan)"
|
|
||||||
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
|
|
||||||
value={locationTerm}
|
|
||||||
onChange={(e) => setLocationTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
|
|
||||||
Rechercher
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Featured Categories */}
|
{/* Featured Categories */}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||||
@@ -127,8 +90,8 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
|||||||
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
|
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
|
||||||
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
|
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/blog" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
|
<Link href="/actualites" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
|
||||||
Voir tout le blog <ArrowRight className="w-4 h-4 ml-1" />
|
Voir les actualités <ArrowRight className="w-4 h-4 ml-1" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,7 +108,7 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
|||||||
})
|
})
|
||||||
.slice(0, settings?.homeBlogCount || 3)
|
.slice(0, settings?.homeBlogCount || 3)
|
||||||
.map(post => (
|
.map(post => (
|
||||||
<Link key={post.id} href={`/blog/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
|
<Link key={post.id} href={`/actualites/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
|
||||||
<div className="h-48 overflow-hidden">
|
<div className="h-48 overflow-hidden">
|
||||||
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
|
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
52
app/actions/auth.ts
Normal file
52
app/actions/auth.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function reactivateUserAccount(userId: string, reason: string) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, name: true, email: true, deletedAt: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Utilisateur non trouvé" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.deletedAt) {
|
||||||
|
return { success: false, error: "Ce compte est déjà actif" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
deletedAt: null,
|
||||||
|
deletionScheduledAt: null,
|
||||||
|
reactivationReason: reason
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionnel : Envoyer un mail de confirmation
|
||||||
|
const settings = await prisma.siteSetting.findUnique({ where: { id: "singleton" } });
|
||||||
|
if (settings?.reactivateAccountTemplate) {
|
||||||
|
const { sendEmail } = await import("@/lib/mail");
|
||||||
|
const html = settings.reactivateAccountTemplate
|
||||||
|
.replace(/{name}/g, user.name)
|
||||||
|
.replace(/{siteName}/g, settings.siteName)
|
||||||
|
.replace(/{reason}/g, reason);
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: settings.reactivateAccountSubject,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Reactivation Error:", error);
|
||||||
|
return { success: false, error: "Une erreur est survenue lors de la réactivation" };
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/actions/events.ts
Normal file
42
app/actions/events.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function createEvent(data: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
date: string;
|
||||||
|
location: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
link?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
date: new Date(data.date),
|
||||||
|
location: data.location,
|
||||||
|
thumbnailUrl: data.thumbnailUrl,
|
||||||
|
link: data.link || null,
|
||||||
|
tags: data.tags || [],
|
||||||
|
status: 'PENDING' as ContentStatus, // Always pending for user submission
|
||||||
|
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/evenements");
|
||||||
|
return { success: true, event };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create event:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création de l'événement" };
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/actions/news.ts
Normal file
42
app/actions/news.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function createNews(data: {
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
imageUrl: string;
|
||||||
|
tags?: string[];
|
||||||
|
sources?: any;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
|
||||||
|
const post = await prisma.blogPost.create({
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
excerpt: data.excerpt,
|
||||||
|
content: data.content,
|
||||||
|
author: data.author,
|
||||||
|
imageUrl: data.imageUrl,
|
||||||
|
tags: data.tags || [],
|
||||||
|
sources: data.sources || [],
|
||||||
|
status: 'PENDING' as ContentStatus, // Always pending for user submission
|
||||||
|
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/actualites");
|
||||||
|
return { success: true, post };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create news:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création de l'actualité" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,13 @@ export async function uploadImage(formData: FormData) {
|
|||||||
|
|
||||||
// Convert to Base64
|
// Convert to Base64
|
||||||
const base64 = buffer.toString('base64');
|
const base64 = buffer.toString('base64');
|
||||||
const mimeType = file.type || 'image/jpeg';
|
let mimeType = file.type || 'image/jpeg';
|
||||||
|
|
||||||
|
// Normalize JPEG mime types
|
||||||
|
if (mimeType === 'image/jpg' || mimeType === 'image/pjpeg') {
|
||||||
|
mimeType = 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||||
|
|
||||||
// Return the data URL directly (it will be saved in the database)
|
// Return the data URL directly (it will be saved in the database)
|
||||||
|
|||||||
270
app/actualites/[id]/page.tsx
Normal file
270
app/actualites/[id]/page.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { ArrowLeft, ArrowRight, User, Calendar, Share2 } from 'lucide-react';
|
||||||
|
import { sanitizeHTML } from '../../../lib/sanitize';
|
||||||
|
import { prisma } from '../../../lib/prisma';
|
||||||
|
import BlogShareButtons from '../../../components/BlogShareButtons';
|
||||||
|
import LightboxImage from '@/components/LightboxImage';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style spécifique pour empêcher strictement la coupure des mots tout en justifiant le texte
|
||||||
|
const noBreakStyle = `
|
||||||
|
#blog-content,
|
||||||
|
#blog-content p,
|
||||||
|
#blog-content li,
|
||||||
|
#blog-content span,
|
||||||
|
#blog-content strong {
|
||||||
|
word-break: normal !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
hyphens: none !important;
|
||||||
|
line-break: normal !important;
|
||||||
|
text-align: justify !important;
|
||||||
|
text-justify: inter-word !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sécurité supplémentaire pour les navigateurs mobiles */
|
||||||
|
.prose * {
|
||||||
|
word-break: normal !important;
|
||||||
|
hyphens: none !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const post = await prisma.blogPost.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ id: id }, { slug: id }],
|
||||||
|
publishedAt: { lte: new Date() },
|
||||||
|
status: 'PUBLISHED'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) return { title: 'Article non trouvé' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: post.metaTitle || post.title,
|
||||||
|
description: post.metaDescription || post.excerpt,
|
||||||
|
keywords: post.tags,
|
||||||
|
openGraph: {
|
||||||
|
title: post.metaTitle || post.title,
|
||||||
|
description: post.metaDescription || post.excerpt,
|
||||||
|
images: [post.imageUrl],
|
||||||
|
type: 'article',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogPostPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
const post = await prisma.blogPost.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ id: id }, { slug: id }],
|
||||||
|
publishedAt: { lte: new Date() },
|
||||||
|
status: 'PUBLISHED'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Articles en rapport : par tags communs, sinon les plus récents
|
||||||
|
let relatedPosts = await prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: { lte: new Date() },
|
||||||
|
id: { not: post.id },
|
||||||
|
tags: { hasSome: post.tags }
|
||||||
|
},
|
||||||
|
take: 3,
|
||||||
|
orderBy: { date: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compléter avec les articles récents si nécessaire
|
||||||
|
if (relatedPosts.length < 2) {
|
||||||
|
const excludedIds = [post.id, ...relatedPosts.map(p => p.id)];
|
||||||
|
const additionalPosts = await prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: { lte: new Date() },
|
||||||
|
id: { notIn: excludedIds }
|
||||||
|
},
|
||||||
|
take: 3 - relatedPosts.length,
|
||||||
|
orderBy: { date: 'desc' }
|
||||||
|
});
|
||||||
|
relatedPosts = [...relatedPosts, ...additionalPosts];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="bg-white sm:bg-gray-50 min-h-screen py-0 sm:py-12 relative">
|
||||||
|
{/* Injection du style de force pour le texte */}
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: noBreakStyle }} />
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto px-0 sm:px-6 lg:px-8 flex gap-4 lg:gap-8 justify-center relative">
|
||||||
|
{/* Conteneur principal de l'article */}
|
||||||
|
<div className="w-full max-w-3xl relative">
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="mb-4 sm:mb-8 px-4 sm:px-0 pt-4 sm:pt-0">
|
||||||
|
<Link href="/actualites" className="inline-flex items-center text-gray-500 hover:text-brand-600 text-sm font-medium transition-colors">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white sm:rounded-xl shadow-none sm:shadow-sm sm:p-10 border-none sm:border sm:border-gray-100 relative z-10">
|
||||||
|
{/* Image d'en-tête en pleine largeur sur mobile */}
|
||||||
|
<div className="w-full h-64 md:h-80 relative mb-6 sm:mb-8 sm:rounded-lg overflow-hidden shadow-none sm:shadow-md">
|
||||||
|
<LightboxImage
|
||||||
|
src={post.imageUrl}
|
||||||
|
alt={post.title}
|
||||||
|
position={post.coverPosition || "50% 50%"}
|
||||||
|
zoom={post.coverZoom || 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header de l'article */}
|
||||||
|
<header className="mb-6 sm:mb-8 border-b border-gray-100 pb-6 sm:pb-8 px-4 sm:px-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<User className="w-4 h-4 mr-1 text-brand-500" />
|
||||||
|
{post.author}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
|
||||||
|
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
{post.tags.length > 0 ? (
|
||||||
|
post.tags.map(tag => (
|
||||||
|
<span key={tag} className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||||
|
Article
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Contenu de l'article */}
|
||||||
|
<div className="prose sm:prose-lg prose-orange max-w-none text-gray-600 px-4 sm:px-0">
|
||||||
|
{/* Résumé / Lead */}
|
||||||
|
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Corps du texte avec ID pour le contrôle CSS */}
|
||||||
|
<div
|
||||||
|
id="blog-content"
|
||||||
|
className="mt-6"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeHTML(post.content) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sources Section */}
|
||||||
|
{Array.isArray(post.sources) && post.sources.length > 0 && (
|
||||||
|
<div className="mt-8 sm:mt-12 p-4 sm:p-6 bg-gray-50 sm:rounded-xl border-t sm:border border-gray-100 mx-4 sm:mx-0">
|
||||||
|
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-400 mb-4 flex items-center gap-2">
|
||||||
|
<Share2 className="w-4 h-4 text-brand-500" />
|
||||||
|
Sources & Références
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{(post.sources as any[]).map((source, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-3">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-500 mt-2 shrink-0" />
|
||||||
|
<a
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-700 hover:text-brand-600 transition-colors break-all"
|
||||||
|
>
|
||||||
|
<span className="font-semibold">{source.title || 'Source'}</span>
|
||||||
|
<span className="text-gray-400 ml-2 text-sm">{source.url}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Partage sur mobile */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-100 block sm:hidden px-4">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-4 text-center">Partager cet article</p>
|
||||||
|
<BlogShareButtons title={post.title} layout="horizontal" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Partage / Footer */}
|
||||||
|
<div className="mt-8 sm:mt-12 pt-6 sm:pt-8 border-t border-gray-100 flex flex-col sm:flex-row justify-between items-center gap-4 px-4 sm:px-0">
|
||||||
|
<p className="text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
||||||
|
<Link
|
||||||
|
href="/actualites"
|
||||||
|
className="inline-flex items-center text-brand-600 hover:text-brand-700 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Lire d'autres articles
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar flottante verticale à droite (suit l'article et s'arrête à la fin) */}
|
||||||
|
<div className="hidden sm:block w-10 shrink-0 relative">
|
||||||
|
<div className="sticky top-32 z-50">
|
||||||
|
<BlogShareButtons title={post.title} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Articles en rapport */}
|
||||||
|
{relatedPosts.length > 0 && (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 mt-16 pt-16 border-t border-gray-200">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-serif font-bold text-gray-900 mb-8">Articles en rapport</h2>
|
||||||
|
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{relatedPosts.map((rPost) => (
|
||||||
|
<Link
|
||||||
|
key={rPost.id}
|
||||||
|
href={`/actualites/${rPost.slug || rPost.id}`}
|
||||||
|
className="group bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="aspect-video relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={rPost.imageUrl}
|
||||||
|
alt={rPost.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/5 group-hover:bg-transparent transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-600 bg-brand-50 px-2 py-0.5 rounded">
|
||||||
|
{rPost.tags?.[0] || 'Article'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{new Date(rPost.date).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 group-hover:text-brand-600 transition-colors line-clamp-2 leading-snug">
|
||||||
|
{rPost.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-500 line-clamp-2">
|
||||||
|
{rPost.excerpt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,9 +26,9 @@ export default async function BlogPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-3xl font-bold font-serif text-gray-900 sm:text-4xl">Le Blog de l'Entrepreneur</h1>
|
<h1 className="text-3xl font-bold font-serif text-gray-900 sm:text-4xl">Nos Actualités</h1>
|
||||||
<p className="mt-3 max-w-2xl mx-auto text-xl text-gray-500 sm:mt-4">
|
<p className="mt-3 max-w-2xl mx-auto text-xl text-gray-500 sm:mt-4">
|
||||||
Conseils, actualités et success stories de l'écosystème africain.
|
Conseils, dossiers et news de l'écosystème africain.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,9 +40,18 @@ export default async function BlogPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-8 lg:grid-cols-3">
|
<div className="grid gap-8 lg:grid-cols-3">
|
||||||
{posts.map(post => (
|
{posts.map(post => (
|
||||||
<Link key={post.id} href={`/blog/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
|
<Link key={post.id} href={`/actualites/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
|
||||||
<div className="flex-shrink-0 relative overflow-hidden h-48">
|
<div className="flex-shrink-0 relative overflow-hidden h-48">
|
||||||
<img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} />
|
<img
|
||||||
|
className="h-full w-full object-cover group-hover:scale-[1.05] transition-transform duration-500"
|
||||||
|
src={post.imageUrl}
|
||||||
|
alt={post.title}
|
||||||
|
style={{
|
||||||
|
objectPosition: post.coverPosition || '50% 50%',
|
||||||
|
transform: `scale(${post.coverZoom || 1})`,
|
||||||
|
transformOrigin: post.coverPosition || '50% 50%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
|
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 bg-white p-6 flex flex-col justify-between">
|
<div className="flex-1 bg-white p-6 flex flex-col justify-between">
|
||||||
@@ -5,6 +5,8 @@ import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight
|
|||||||
import { sanitizeHTML } from '../../../lib/sanitize';
|
import { sanitizeHTML } from '../../../lib/sanitize';
|
||||||
import { prisma } from '../../../lib/prisma';
|
import { prisma } from '../../../lib/prisma';
|
||||||
import { InterviewType } from '@prisma/client';
|
import { InterviewType } from '@prisma/client';
|
||||||
|
import LightboxImage from '@/components/LightboxImage';
|
||||||
|
import BlogShareButtons from '../../../components/BlogShareButtons';
|
||||||
|
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
@@ -90,13 +92,15 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white min-h-screen">
|
<div className="bg-white min-h-screen">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-4xl mx-auto px-0 sm:px-6 lg:px-8 py-0 sm:py-12">
|
||||||
<Link href="/afrolife" className="inline-flex items-center text-gray-500 hover:text-brand-600 mb-8 transition-colors">
|
<div className="pt-4 sm:pt-0 px-4 sm:px-0 mb-6 sm:mb-8">
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" /> Retour à Afro Life
|
<Link href="/afrolife" className="inline-flex items-center text-gray-500 hover:text-brand-600 transition-colors">
|
||||||
</Link>
|
<ArrowLeft className="w-4 h-4 mr-2" /> Retour à Afro Life
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Header Info */}
|
{/* Header Info */}
|
||||||
<div className="text-center mb-10">
|
<div className="text-center mb-8 sm:mb-10 px-4 sm:px-0">
|
||||||
<div className="inline-flex items-center justify-center px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-xs font-bold uppercase tracking-wider mb-4">
|
<div className="inline-flex items-center justify-center px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-xs font-bold uppercase tracking-wider mb-4">
|
||||||
{isEvent ? 'Événement' : (isVideo ? 'Interview Vidéo' : 'Grand Entretien')}
|
{isEvent ? 'Événement' : (isVideo ? 'Interview Vidéo' : 'Grand Entretien')}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,9 +146,9 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area en pleine largeur sur mobile */}
|
||||||
{isVideo ? (
|
{isVideo ? (
|
||||||
<div className="bg-black rounded-xl overflow-hidden shadow-2xl aspect-w-16 aspect-h-9 mb-10">
|
<div className="bg-black rounded-none sm:rounded-xl overflow-hidden shadow-none sm:shadow-2xl aspect-w-16 aspect-h-9 mb-8 sm:mb-10 w-full">
|
||||||
{(interview as any).videoUrl ? (
|
{(interview as any).videoUrl ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={getEmbedUrl((interview as any).videoUrl) || ''}
|
src={getEmbedUrl((interview as any).videoUrl) || ''}
|
||||||
@@ -160,14 +164,18 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-64 md:h-96 rounded-xl overflow-hidden shadow-lg mb-10">
|
<div className="relative h-64 md:h-96 rounded-none sm:rounded-xl overflow-hidden shadow-none sm:shadow-lg mb-8 sm:mb-10 w-full">
|
||||||
<img src={item!.thumbnailUrl} alt={item!.title} className="w-full h-full object-cover" />
|
<LightboxImage
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
|
src={item!.thumbnailUrl}
|
||||||
|
alt={item!.title}
|
||||||
|
position={(item as any).coverPosition || "50% 50%"}
|
||||||
|
zoom={(item as any).coverZoom || 1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description / Article Content / Event Content */}
|
{/* Description / Article Content / Event Content */}
|
||||||
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600">
|
<div className="prose sm:prose-lg prose-orange max-w-none mx-auto text-gray-600 px-4 sm:px-0">
|
||||||
{isEvent ? (
|
{isEvent ? (
|
||||||
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} />
|
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} />
|
||||||
) : (
|
) : (
|
||||||
@@ -183,7 +191,7 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
|
|
||||||
{/* Event Link Action */}
|
{/* Event Link Action */}
|
||||||
{isEvent && (event as any).link && (
|
{isEvent && (event as any).link && (
|
||||||
<div className="mt-10 text-center">
|
<div className="mt-10 text-center px-4 sm:px-0">
|
||||||
<Link
|
<Link
|
||||||
href={(event as any).link}
|
href={(event as any).link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -194,12 +202,12 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions / Partage */}
|
||||||
<div className="mt-12 pt-8 border-t border-gray-100 flex justify-center">
|
<div className="mt-10 sm:mt-12 pt-6 sm:pt-8 border-t border-gray-100 px-4 sm:px-0 pb-8 sm:pb-0">
|
||||||
<button className="flex items-center px-6 py-3 rounded-full bg-brand-50 text-brand-700 font-medium hover:bg-brand-100 transition-colors">
|
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-4 text-center">
|
||||||
<Share2 className="w-5 h-5 mr-2" />
|
|
||||||
Partager {isEvent ? "cet événement" : "cette interview"}
|
Partager {isEvent ? "cet événement" : "cette interview"}
|
||||||
</button>
|
</p>
|
||||||
|
<BlogShareButtons title={item!.title} layout="horizontal" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,7 +149,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
|
|||||||
<img
|
<img
|
||||||
src={item.thumbnailUrl}
|
src={item.thumbnailUrl}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
className="w-full h-full object-cover group-hover:scale-[1.05] transition-transform duration-700"
|
||||||
|
style={{
|
||||||
|
objectPosition: item.coverPosition || '50% 50%',
|
||||||
|
transform: `scale(${item.coverZoom || 1})`,
|
||||||
|
transformOrigin: item.coverPosition || '50% 50%'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { email, password } = await request.json();
|
const rawBody = await request.json();
|
||||||
|
const email = rawBody.email?.toLowerCase().trim();
|
||||||
|
const password = rawBody.password;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -50,8 +52,25 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Check if email is verified
|
// Check if email is verified
|
||||||
if (!user.emailVerified) {
|
if (!user.emailVerified) {
|
||||||
|
// En environnement local/développement, on valide automatiquement l'email pour fluidifier les tests
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { emailVerified: true }
|
||||||
|
});
|
||||||
|
user.emailVerified = true;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Veuillez vérifier votre adresse email avant de vous connecter.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is marked for deletion
|
||||||
|
if (user.deletedAt) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Veuillez vérifier votre adresse email avant de vous connecter.' },
|
{ error: 'Votre compte est désactivé. Veuillez le réactiver via le lien envoyé par email ou contacter le support.' },
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, email, password } = await request.json();
|
const rawBody = await request.json();
|
||||||
|
const name = rawBody.name;
|
||||||
|
const email = rawBody.email?.toLowerCase().trim();
|
||||||
|
const password = rawBody.password;
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if (!name || !email || !password) {
|
if (!name || !email || !password) {
|
||||||
@@ -47,8 +50,8 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password
|
// Hash password with 10 rounds to match seed and system verification standards
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
// Generate verification token
|
// Generate verification token
|
||||||
const verificationToken = crypto.randomBytes(32).toString('hex');
|
const verificationToken = crypto.randomBytes(32).toString('hex');
|
||||||
@@ -61,7 +64,7 @@ export async function POST(request: NextRequest) {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
role: 'VISITOR', // Default role
|
role: 'VISITOR', // Default role
|
||||||
verificationToken,
|
verificationToken,
|
||||||
emailVerified: false,
|
emailVerified: process.env.NODE_ENV !== 'production', // Auto-vérifié en développement
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,15 +96,57 @@ export async function POST(request: NextRequest) {
|
|||||||
.replace(/{siteName}/g, siteName);
|
.replace(/{siteName}/g, siteName);
|
||||||
|
|
||||||
const { sendEmail } = await import('@/lib/mail');
|
const { sendEmail } = await import('@/lib/mail');
|
||||||
|
|
||||||
|
// Send user verification email
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send admin notification email
|
||||||
|
try {
|
||||||
|
const adminEmail = settings?.contactEmail || 'support@afrohub.com';
|
||||||
|
const adminSubject = `[Admin] Nouvelle inscription sur ${siteName} : ${name}`;
|
||||||
|
const adminHtml = `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #4f46e5; text-align: center;">Notification Admin - Inscription</h2>
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Un nouvel utilisateur vient de s'inscrire sur <strong>${siteName}</strong> :</p>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin-top: 15px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold; width: 35%;">Nom complet :</td>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">${name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Adresse email :</td>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;"><a href="mailto:${email}">${email}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Date d'inscription :</td>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">${new Date().toLocaleString('fr-FR')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Rôle par défaut :</td>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">VISITOR</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin-top: 25px; text-align: center;">
|
||||||
|
<a href="${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/admin" style="background-color: #4f46e5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">Accéder au Dashboard Admin</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: adminEmail,
|
||||||
|
subject: adminSubject,
|
||||||
|
html: adminHtml,
|
||||||
|
});
|
||||||
|
} catch (adminMailError) {
|
||||||
|
console.error('Failed to send admin signup notification email:', adminMailError);
|
||||||
|
}
|
||||||
} catch (mailError) {
|
} catch (mailError) {
|
||||||
console.error('Failed to send verification email:', mailError);
|
console.error('Failed to send registration email(s):', mailError);
|
||||||
// We don't fail registration if mail fails, but maybe we should?
|
|
||||||
// For now, just log it.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Omit password from response
|
// Omit password from response
|
||||||
|
|||||||
14
app/api/one-shot-plans/route.ts
Normal file
14
app/api/one-shot-plans/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const plans = await prisma.oneShotPlan.findMany({
|
||||||
|
orderBy: { type: 'asc' }
|
||||||
|
});
|
||||||
|
return NextResponse.json(plans);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch one-shot plans:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch plans" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/auth/reactivate/ReactivateForm.tsx
Normal file
85
app/auth/reactivate/ReactivateForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
app/auth/reactivate/page.tsx
Normal file
67
app/auth/reactivate/page.tsx
Normal 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 été 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { ArrowLeft, User, Calendar, Share2 } from 'lucide-react';
|
|
||||||
import { sanitizeHTML } from '../../../lib/sanitize';
|
|
||||||
import { prisma } from '../../../lib/prisma';
|
|
||||||
|
|
||||||
import { Metadata } from 'next';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const post = await prisma.blogPost.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ id: id },
|
|
||||||
{ slug: id }
|
|
||||||
],
|
|
||||||
publishedAt: {
|
|
||||||
lte: new Date()
|
|
||||||
},
|
|
||||||
status: 'PUBLISHED'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!post) return { title: 'Article non trouvé' };
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: post.metaTitle || post.title,
|
|
||||||
description: post.metaDescription || post.excerpt,
|
|
||||||
keywords: post.tags,
|
|
||||||
openGraph: {
|
|
||||||
title: post.metaTitle || post.title,
|
|
||||||
description: post.metaDescription || post.excerpt,
|
|
||||||
images: [post.imageUrl],
|
|
||||||
type: 'article',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogPostPage({ params }: Props) {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const post = await prisma.blogPost.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ id: id },
|
|
||||||
{ slug: id }
|
|
||||||
],
|
|
||||||
publishedAt: {
|
|
||||||
lte: new Date()
|
|
||||||
},
|
|
||||||
status: 'PUBLISHED'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!post) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="bg-white min-h-screen">
|
|
||||||
{/* Hero Image */}
|
|
||||||
<div className="w-full h-64 md:h-96 relative">
|
|
||||||
<img
|
|
||||||
src={post.imageUrl}
|
|
||||||
alt={post.title}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
|
||||||
<div className="absolute bottom-0 left-0 w-full p-4 sm:p-8 max-w-4xl mx-auto">
|
|
||||||
<Link href="/blog" className="inline-flex items-center text-white/80 hover:text-white mb-4 text-sm font-medium transition-colors">
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12 -mt-20 relative z-10">
|
|
||||||
<div className="bg-white rounded-xl shadow-xl p-6 md:p-10 border border-gray-100">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="mb-8 border-b border-gray-100 pb-8">
|
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
|
|
||||||
<span className="flex items-center">
|
|
||||||
<User className="w-4 h-4 mr-1 text-brand-500" />
|
|
||||||
{post.author}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
|
|
||||||
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
{post.tags.length > 0 ? (
|
|
||||||
post.tags.map(tag => (
|
|
||||||
<span key={tag} className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
|
||||||
Article
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
|
|
||||||
{post.title}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
|
|
||||||
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
|
|
||||||
{post.excerpt}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className="mt-6"
|
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeHTML(post.content) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer / Share */}
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-100 flex justify-between items-center">
|
|
||||||
<p className="text-sm text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<button className="p-2 rounded-full bg-gray-100 text-gray-600 hover:bg-brand-100 hover:text-brand-600 transition-colors">
|
|
||||||
<Share2 className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import DashboardProfileClient from '../../components/dashboard/DashboardProfileC
|
|||||||
import DashboardMessages from '../../components/dashboard/DashboardMessages';
|
import DashboardMessages from '../../components/dashboard/DashboardMessages';
|
||||||
import DashboardOffers from '../../components/dashboard/DashboardOffers';
|
import DashboardOffers from '../../components/dashboard/DashboardOffers';
|
||||||
import PricingSection from '../../components/PricingSection';
|
import PricingSection from '../../components/PricingSection';
|
||||||
|
import OneShotPricingSection from '../../components/OneShotPricingSection';
|
||||||
import DashboardFavorites from '../../components/dashboard/DashboardFavorites';
|
import DashboardFavorites from '../../components/dashboard/DashboardFavorites';
|
||||||
import { useUser } from '../../components/UserProvider';
|
import { useUser } from '../../components/UserProvider';
|
||||||
|
|
||||||
@@ -242,7 +243,7 @@ const DashboardContent = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button onClick={() => setCurrentView('subscription')} className={`${currentView === 'subscription' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
|
<button onClick={() => setCurrentView('subscription')} className={`${currentView === 'subscription' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
|
||||||
<CreditCard className={`${currentView === 'subscription' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
|
<CreditCard className={`${currentView === 'subscription' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
|
||||||
Abonnement
|
Abonnement & Services
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -326,6 +327,7 @@ const DashboardContent = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<PricingSection />
|
<PricingSection />
|
||||||
|
<OneShotPricingSection />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,16 +23,68 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose {
|
/* FORCE ABSOLUE SUR LE CONTENU DU BLOG
|
||||||
text-align: justify;
|
L'utilisation de l'ID #blog-content garantit que ces règles
|
||||||
hyphens: none;
|
écrasent les styles par défaut du plugin typography.
|
||||||
word-break: normal;
|
*/
|
||||||
overflow-wrap: normal;
|
#blog-content,
|
||||||
overflow-x: auto;
|
#blog-content *,
|
||||||
|
.prose p,
|
||||||
|
.prose li {
|
||||||
|
word-break: keep-all !important;
|
||||||
|
/* Interdit de couper les mots */
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
/* Autorise le retour à la ligne si le mot est trop long */
|
||||||
|
hyphens: none !important;
|
||||||
|
/* Supprime les tirets de césure */
|
||||||
|
line-break: normal !important;
|
||||||
|
/* Empêche les cassures de caractères bizarres */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nettoyage des titres */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
word-break: normal !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
hyphens: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose p {
|
.prose p {
|
||||||
margin-bottom: 1.25em;
|
margin-bottom: 1.25em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour les tableaux dans le contenu */
|
||||||
|
#blog-content table,
|
||||||
|
.prose table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blog-content th,
|
||||||
|
#blog-content td,
|
||||||
|
.prose th,
|
||||||
|
.prose td {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blog-content th,
|
||||||
|
.prose th {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blog-content tr:nth-child(even),
|
||||||
|
.prose tr:nth-child(even) {
|
||||||
|
background-color: #fcfcfc;
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Home, ArrowLeft, Search } from 'lucide-react';
|
import { Home, Search } from 'lucide-react';
|
||||||
|
import { getSiteSettings } from '../lib/settings';
|
||||||
|
|
||||||
|
export default async function NotFound() {
|
||||||
|
const settings = await getSiteSettings();
|
||||||
|
const quotes = (settings as any)?.notFoundQuotes && (settings as any).notFoundQuotes.length > 0
|
||||||
|
? (settings as any).notFoundQuotes
|
||||||
|
: ["L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."];
|
||||||
|
|
||||||
|
// Sélection d'une phrase aléatoire
|
||||||
|
const randomQuote = quotes[Math.floor(Math.random() * quotes.length)];
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex items-center justify-center px-4 sm:px-6 lg:px-8 py-24">
|
<div className="min-h-screen bg-white flex items-center justify-center px-4 sm:px-6 lg:px-8 py-24">
|
||||||
<div className="max-w-md w-full text-center">
|
<div className="max-w-md w-full text-center">
|
||||||
@@ -41,10 +50,11 @@ export default function NotFound() {
|
|||||||
|
|
||||||
<div className="mt-12 pt-12 border-t border-gray-100">
|
<div className="mt-12 pt-12 border-t border-gray-100">
|
||||||
<p className="text-sm text-gray-400 italic">
|
<p className="text-sm text-gray-400 italic">
|
||||||
"L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."
|
"{randomQuote}"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
app/page.tsx
11
app/page.tsx
@@ -44,9 +44,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
// Fetch initial data for faster loading and SEO
|
// Fetch initial data for faster loading and SEO
|
||||||
const [featuredBusinesses, posts, categories] = await Promise.all([
|
const [featuredBusinesses, posts, categories, slides] = await Promise.all([
|
||||||
prisma.business.findMany({
|
prisma.business.findMany({
|
||||||
where: { isFeatured: true, isActive: true, isSuspended: false },
|
where: { isHomeFeatured: true, isActive: true, isSuspended: false },
|
||||||
take: 4,
|
take: 4,
|
||||||
include: { categoryRef: true }
|
include: { categoryRef: true }
|
||||||
}),
|
}),
|
||||||
@@ -58,6 +58,11 @@ export default async function HomePage() {
|
|||||||
prisma.businessCategory.findMany({
|
prisma.businessCategory.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
take: 8
|
take: 8
|
||||||
|
}),
|
||||||
|
prisma.homeSlide.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: { business: true }
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -65,12 +70,14 @@ export default async function HomePage() {
|
|||||||
const initialFeatured = JSON.parse(JSON.stringify(featuredBusinesses));
|
const initialFeatured = JSON.parse(JSON.stringify(featuredBusinesses));
|
||||||
const initialPosts = JSON.parse(JSON.stringify(posts));
|
const initialPosts = JSON.parse(JSON.stringify(posts));
|
||||||
const initialCategories = JSON.parse(JSON.stringify(categories));
|
const initialCategories = JSON.parse(JSON.stringify(categories));
|
||||||
|
const initialSlides = JSON.parse(JSON.stringify(slides));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeClient
|
<HomeClient
|
||||||
initialFeatured={initialFeatured}
|
initialFeatured={initialFeatured}
|
||||||
initialPosts={initialPosts}
|
initialPosts={initialPosts}
|
||||||
initialCategories={initialCategories}
|
initialCategories={initialCategories}
|
||||||
|
initialSlides={initialSlides}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PricingSection from '../../components/PricingSection';
|
import PricingSection from '../../components/PricingSection';
|
||||||
|
import OneShotPricingSection from '../../components/OneShotPricingSection';
|
||||||
|
|
||||||
const SubscriptionPage = () => {
|
const SubscriptionPage = () => {
|
||||||
return (
|
return (
|
||||||
@@ -12,6 +13,7 @@ const SubscriptionPage = () => {
|
|||||||
<p className="text-gray-400 mt-2">Rejoignez la plus grande communauté d'entrepreneurs.</p>
|
<p className="text-gray-400 mt-2">Rejoignez la plus grande communauté d'entrepreneurs.</p>
|
||||||
</div>
|
</div>
|
||||||
<PricingSection />
|
<PricingSection />
|
||||||
|
<OneShotPricingSection />
|
||||||
|
|
||||||
{/* FAQ Section */}
|
{/* FAQ Section */}
|
||||||
<div className="max-w-4xl mx-auto px-4 py-16">
|
<div className="max-w-4xl mx-auto px-4 py-16">
|
||||||
|
|||||||
13
check-posts.ts
Normal file
13
check-posts.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const posts = await prisma.blogPost.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(posts, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
31
check_db.cjs
Normal file
31
check_db.cjs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const post = await prisma.blogPost.findUnique({
|
||||||
|
where: { slug: 'cyber-dome-cato' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
console.log("Post not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippet = post.content.substring(0, 1000);
|
||||||
|
console.log("Content start:");
|
||||||
|
console.log(snippet);
|
||||||
|
|
||||||
|
const match = post.content.match(/s.e sont/);
|
||||||
|
if (match) {
|
||||||
|
console.log("Found 's.e sont':", match[0]);
|
||||||
|
console.log("Hex:", Buffer.from(match[0]).toString('hex'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const match2 = post.content.match(/cyberattaqu.es/);
|
||||||
|
if (match2) {
|
||||||
|
console.log("Found 'cyberattaqu.es':", match2[0]);
|
||||||
|
console.log("Hex:", Buffer.from(match2[0]).toString('hex'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
@@ -56,9 +56,11 @@ const AnnuaireHero = ({ featuredBusiness }: { featuredBusiness: Business }) => {
|
|||||||
<h2 className="text-3xl md:text-4xl font-bold font-serif text-gray-900 mb-2">
|
<h2 className="text-3xl md:text-4xl font-bold font-serif text-gray-900 mb-2">
|
||||||
{featuredBusiness.name}
|
{featuredBusiness.name}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-gray-600 mb-4 font-medium">
|
{featuredBusiness.founderName && (
|
||||||
Dirigé par <span className="text-brand-600">{featuredBusiness.founderName}</span>
|
<p className="text-lg text-gray-600 mb-4 font-medium">
|
||||||
</p>
|
Dirigé par <span className="text-brand-600">{featuredBusiness.founderName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-gray-500 mb-6">
|
<div className="flex flex-wrap gap-4 text-sm text-gray-500 mb-6">
|
||||||
<div className="flex items-center"><Briefcase className="w-4 h-4 mr-1"/> {featuredBusiness.category}</div>
|
<div className="flex items-center"><Briefcase className="w-4 h-4 mr-1"/> {featuredBusiness.category}</div>
|
||||||
|
|||||||
74
components/BlogShareButtons.tsx
Normal file
74
components/BlogShareButtons.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Facebook, Instagram, Twitter, Link as LinkIcon, Check } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
layout?: 'vertical' | 'horizontal';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlogShareButtons({ title, layout = 'vertical' }: Props) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUrl(window.location.href);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = (customMessage?: string) => {
|
||||||
|
if (!url) return;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success(customMessage || 'Lien copié dans le presse-papiers !');
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareLinks = {
|
||||||
|
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||||
|
twitter: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex ${layout === 'horizontal' ? 'flex-row gap-4 justify-center flex-wrap' : 'flex-col gap-3'} z-50`}>
|
||||||
|
<a
|
||||||
|
href={shareLinks.facebook}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="w-10 h-10 rounded-full bg-white text-[#1877F2] shadow-xl flex items-center justify-center hover:scale-110 transition-transform border border-gray-100"
|
||||||
|
title="Partager sur Facebook"
|
||||||
|
>
|
||||||
|
<Facebook className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={shareLinks.twitter}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="w-10 h-10 rounded-full bg-white text-black shadow-xl flex items-center justify-center hover:scale-110 transition-transform border border-gray-100"
|
||||||
|
title="Partager sur X (Twitter)"
|
||||||
|
>
|
||||||
|
<Twitter className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy('Lien copié ! Vous pouvez le coller sur Instagram.')}
|
||||||
|
className="w-10 h-10 rounded-full bg-gradient-to-tr from-[#f9ce34] via-[#ee2a7b] to-[#6228d7] text-white shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
|
||||||
|
title="Copier le lien pour Instagram"
|
||||||
|
>
|
||||||
|
<Instagram className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy()}
|
||||||
|
className="w-10 h-10 rounded-full bg-gray-800 text-white shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
|
||||||
|
title="Copier le lien"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-5 h-5 text-green-400" /> : <LinkIcon className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,7 +67,8 @@ const Footer = ({ settings }: FooterProps) => {
|
|||||||
<ul className="space-y-2 text-gray-400 text-sm">
|
<ul className="space-y-2 text-gray-400 text-sm">
|
||||||
<li><Link href="/annuaire" className="hover:text-brand-500">Rechercher une entreprise</Link></li>
|
<li><Link href="/annuaire" className="hover:text-brand-500">Rechercher une entreprise</Link></li>
|
||||||
<li><Link href="/login" className="hover:text-brand-500">Inscrire mon entreprise</Link></li>
|
<li><Link href="/login" className="hover:text-brand-500">Inscrire mon entreprise</Link></li>
|
||||||
<li><Link href="/blog" className="hover:text-brand-500">Actualités & Conseils</Link></li>
|
<li><Link href="/actualites" className="hover:text-brand-500">Actualités & Conseils</Link></li>
|
||||||
|
<li><Link href="/subscription" className="hover:text-brand-500">Tarifs</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
220
components/HomeSlider.tsx
Normal file
220
components/HomeSlider.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Search, MapPin, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface HomeSlide {
|
||||||
|
id: string;
|
||||||
|
type: 'BUSINESS' | 'CUSTOM';
|
||||||
|
businessId?: string | null;
|
||||||
|
business?: any;
|
||||||
|
title?: string | null;
|
||||||
|
subtitle?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
linkUrl?: string | null;
|
||||||
|
coverPosition?: string | null;
|
||||||
|
coverZoom?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
slides: HomeSlide[];
|
||||||
|
onSearch: (searchTerm: string, locationTerm: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomeSlider = ({ slides, onSearch }: Props) => {
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [locationTerm, setLocationTerm] = useState('');
|
||||||
|
|
||||||
|
// Auto-slide every 6 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (slides.length <= 1) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrent((prev) => (prev === slides.length - 1 ? 0 : prev + 1));
|
||||||
|
}, 6000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [slides.length]);
|
||||||
|
|
||||||
|
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearch(searchTerm, locationTerm);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If no slides, show default hero
|
||||||
|
if (slides.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="relative bg-dark-900 overflow-hidden min-h-[600px] flex items-center">
|
||||||
|
<div className="absolute inset-0 opacity-40">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
alt="Default Banner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32 w-full">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight animate-in fade-in slide-in-from-left-4 duration-700">
|
||||||
|
Boostez votre visibilité dans <br />
|
||||||
|
<span className="text-brand-500">l'écosystème africain</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-300 mb-8 max-w-2xl animate-in fade-in slide-in-from-left-4 duration-700 delay-150">
|
||||||
|
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
|
||||||
|
</p>
|
||||||
|
<SearchForm
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
setSearchTerm={setSearchTerm}
|
||||||
|
locationTerm={locationTerm}
|
||||||
|
setLocationTerm={setLocationTerm}
|
||||||
|
onSubmit={handleSearchSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-[650px] md:h-[700px] overflow-hidden bg-black">
|
||||||
|
{slides.map((slide, index) => (
|
||||||
|
<div
|
||||||
|
key={slide.id}
|
||||||
|
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
||||||
|
index === current ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Background Image */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<img
|
||||||
|
src={slide.imageUrl || slide.business?.coverUrl || slide.business?.logoUrl || ''}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
objectPosition: slide.type === 'BUSINESS' ? (slide.business?.coverPosition || '50% 50%') : (slide.coverPosition || '50% 50%'),
|
||||||
|
transformOrigin: slide.type === 'BUSINESS' ? (slide.business?.coverPosition || '50% 50%') : (slide.coverPosition || '50% 50%'),
|
||||||
|
'--base-zoom': slide.type === 'BUSINESS' ? (slide.business?.coverZoom || 1) : (slide.coverZoom || 1)
|
||||||
|
} as any}
|
||||||
|
className={`w-full h-full object-cover transition-transform duration-[6000ms] ease-linear ${
|
||||||
|
index === current ? 'scale-[calc(var(--base-zoom)*1.1)]' : 'scale-[var(--base-zoom)]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/40 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative h-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center">
|
||||||
|
<div className={`max-w-3xl transition-all duration-700 delay-300 ${
|
||||||
|
index === current ? 'translate-x-0 opacity-100' : '-translate-x-10 opacity-0'
|
||||||
|
}`}>
|
||||||
|
{slide.type === 'BUSINESS' && (
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 bg-brand-600/20 border border-brand-500/30 rounded-full mb-6">
|
||||||
|
<span className="w-2 h-2 bg-brand-500 rounded-full animate-pulse" />
|
||||||
|
<span className="text-brand-400 text-xs font-bold uppercase tracking-widest">Entreprise à la une</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight leading-tight">
|
||||||
|
{slide.type === 'BUSINESS' ? (
|
||||||
|
<>Découvrez <span className="text-brand-500">{slide.business?.name}</span></>
|
||||||
|
) : (
|
||||||
|
slide.title
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-300 mb-8 max-w-2xl line-clamp-3">
|
||||||
|
{slide.type === 'BUSINESS' ? (
|
||||||
|
slide.business?.description
|
||||||
|
) : (
|
||||||
|
slide.subtitle
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 items-center">
|
||||||
|
<Link
|
||||||
|
href={slide.type === 'BUSINESS' ? `/annuaire/${slide.business?.id}` : (slide.linkUrl || '#')}
|
||||||
|
className="inline-flex items-center gap-2 bg-brand-600 text-white px-8 py-4 rounded-lg font-bold hover:bg-brand-700 transition-all shadow-lg shadow-brand-600/20 hover:scale-105"
|
||||||
|
>
|
||||||
|
{slide.type === 'BUSINESS' ? 'Voir la fiche' : 'En savoir plus'}
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Static Search Overlay (Always visible on top of slides) */}
|
||||||
|
<div className="absolute bottom-12 md:bottom-20 left-0 right-0 z-20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<SearchForm
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
setSearchTerm={setSearchTerm}
|
||||||
|
locationTerm={locationTerm}
|
||||||
|
setLocationTerm={setLocationTerm}
|
||||||
|
onSubmit={handleSearchSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Arrows */}
|
||||||
|
{slides.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrent(current === 0 ? slides.length - 1 : current - 1)}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 z-30 p-3 rounded-full bg-white/10 text-white hover:bg-white/20 transition-all backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrent(current === slides.length - 1 ? 0 : current + 1)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 z-30 p-3 rounded-full bg-white/10 text-white hover:bg-white/20 transition-all backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Indicators */}
|
||||||
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-30 flex gap-2">
|
||||||
|
{slides.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setCurrent(i)}
|
||||||
|
className={`w-2.5 h-2.5 rounded-full transition-all ${
|
||||||
|
i === current ? 'bg-brand-500 w-8' : 'bg-white/40'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SearchForm = ({ searchTerm, setSearchTerm, locationTerm, setLocationTerm, onSubmit }: any) => (
|
||||||
|
<form onSubmit={onSubmit} className="max-w-4xl bg-white/95 backdrop-blur-md p-2 rounded-xl shadow-2xl flex flex-col md:flex-row gap-2 border border-white/20 animate-in fade-in slide-in-from-bottom-4 duration-700 delay-500">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-3.5 text-gray-400 w-5 h-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Que recherchez-vous ? (ex: Traiteur, Photographe...)"
|
||||||
|
className="w-full pl-10 pr-4 py-3.5 rounded-lg focus:outline-none text-gray-900 font-medium"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-100">
|
||||||
|
<MapPin className="absolute left-3 top-3.5 text-gray-400 w-5 h-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Où ? (ex: Dakar)"
|
||||||
|
className="w-full pl-10 pr-4 py-3.5 rounded-lg focus:outline-none text-gray-900 font-medium"
|
||||||
|
value={locationTerm}
|
||||||
|
onChange={(e) => setLocationTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="bg-brand-600 text-white px-10 py-3.5 rounded-lg font-bold hover:bg-brand-700 transition-all hover:scale-105 active:scale-95 shadow-lg shadow-brand-600/30">
|
||||||
|
Rechercher
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default HomeSlider;
|
||||||
99
components/LightboxImage.tsx
Normal file
99
components/LightboxImage.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,8 +42,8 @@ const Navbar = ({ settings }: NavbarProps) => {
|
|||||||
<Link href="/afrolife" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/afrolife') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-brand-600') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
<Link href="/afrolife" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/afrolife') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-brand-600') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||||
Afro Life
|
Afro Life
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/blog" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/blog') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
<Link href="/actualites" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/actualites') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||||
Blog
|
Actualités
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +77,7 @@ const Navbar = ({ settings }: NavbarProps) => {
|
|||||||
<Link href="/" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Accueil</Link>
|
<Link href="/" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Accueil</Link>
|
||||||
<Link href="/annuaire" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Annuaire</Link>
|
<Link href="/annuaire" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Annuaire</Link>
|
||||||
<Link href="/afrolife" className="block pl-3 pr-4 py-2 border-l-4 border-brand-500 text-base font-medium text-brand-700 bg-brand-50" onClick={() => setIsOpen(false)}>Afro Life</Link>
|
<Link href="/afrolife" className="block pl-3 pr-4 py-2 border-l-4 border-brand-500 text-base font-medium text-brand-700 bg-brand-50" onClick={() => setIsOpen(false)}>Afro Life</Link>
|
||||||
<Link href="/blog" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Blog</Link>
|
<Link href="/actualites" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Actualités</Link>
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link href="/dashboard" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Mon Espace</Link>
|
<Link href="/dashboard" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Mon Espace</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
251
components/OneShotPricingSection.tsx
Normal file
251
components/OneShotPricingSection.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Zap, Calendar, Newspaper, Layout, Check, Smartphone, CreditCard, Loader, ShieldCheck, X } from 'lucide-react';
|
||||||
|
import { useUser } from './UserProvider';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import CreateEventModal from './dashboard/CreateEventModal';
|
||||||
|
import CreateNewsModal from './dashboard/CreateNewsModal';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface OneShotPlan {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
priceXOF: number;
|
||||||
|
priceEUR: number;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS = {
|
||||||
|
BUMP: Zap,
|
||||||
|
POST_EVENT: Calendar,
|
||||||
|
POST_NEWS: Newspaper,
|
||||||
|
AD_DIRECTORY: Layout,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OneShotPricingSection = () => {
|
||||||
|
const [plans, setPlans] = useState<OneShotPlan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<OneShotPlan | null>(null);
|
||||||
|
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
|
||||||
|
const [paymentStep, setPaymentStep] = useState<'method' | 'processing' | 'success'>('method');
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<'mobile_money' | 'card' | null>(null);
|
||||||
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
|
const [showNewsModal, setShowNewsModal] = useState(false);
|
||||||
|
const { user } = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/one-shot-plans');
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.error) {
|
||||||
|
setPlans(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching one-shot plans:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectPlan = (plan: OneShotPlan) => {
|
||||||
|
if (!user) {
|
||||||
|
toast.error("Veuillez vous connecter pour commander un service");
|
||||||
|
router.push('/login?callback=/subscription');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedPlan(plan);
|
||||||
|
setPaymentStep('method');
|
||||||
|
setIsPaymentModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePayment = () => {
|
||||||
|
setPaymentStep('processing');
|
||||||
|
setTimeout(() => {
|
||||||
|
setPaymentStep('success');
|
||||||
|
}, 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePayment = () => {
|
||||||
|
setIsPaymentModalOpen(false);
|
||||||
|
setPaymentStep('method');
|
||||||
|
setSelectedPlan(null);
|
||||||
|
setPaymentMethod(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!loading && plans.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-base font-semibold text-brand-600 tracking-wide uppercase">Services à la carte</h2>
|
||||||
|
<p className="mt-1 text-3xl font-extrabold text-gray-900 sm:text-4xl font-serif">
|
||||||
|
Boostez votre visibilité ponctuellement
|
||||||
|
</p>
|
||||||
|
<p className="max-w-xl mt-4 mx-auto text-lg text-gray-500">
|
||||||
|
Des options flexibles pour mettre en avant vos actualités et vos offres sans engagement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{loading ? (
|
||||||
|
[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="bg-gray-50 rounded-2xl p-6 h-64 animate-pulse border border-gray-100">
|
||||||
|
<div className="w-12 h-12 bg-gray-200 rounded-xl mb-4"></div>
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-full mb-8"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded w-full"></div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
plans.map((plan) => {
|
||||||
|
const Icon = TYPE_ICONS[plan.type as keyof typeof TYPE_ICONS] || Zap;
|
||||||
|
return (
|
||||||
|
<div key={plan.id} className="bg-gray-50 rounded-2xl p-8 border border-gray-100 hover:border-brand-200 hover:shadow-lg transition-all group flex flex-col">
|
||||||
|
<div className="w-14 h-14 bg-brand-50 rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||||
|
<Icon className="w-8 h-8 text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 font-serif mb-2">{plan.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-6 flex-1">{plan.description}</p>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<span className="text-3xl font-black text-gray-900">{plan.priceXOF.toLocaleString()} F CFA</span>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">env. {plan.priceEUR} €</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelectPlan(plan)}
|
||||||
|
className="w-full bg-white border-2 border-brand-600 text-brand-600 hover:bg-brand-600 hover:text-white font-bold py-3 rounded-xl transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
Commander
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PAYMENT MODAL (Simplified copy from PricingSection) */}
|
||||||
|
{isPaymentModalOpen && selectedPlan && (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-center min-h-screen px-4">
|
||||||
|
<div className="fixed inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity" onClick={closePayment}></div>
|
||||||
|
<div className="relative bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all max-w-lg w-full">
|
||||||
|
<div className="px-6 py-8">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 font-serif">Paiement Service Ponctuel</h3>
|
||||||
|
<button onClick={closePayment} className="text-gray-400 hover:text-gray-600"><X className="h-6 w-6" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{paymentStep === 'method' && (
|
||||||
|
<>
|
||||||
|
<div className="bg-brand-50 p-5 rounded-2xl mb-8 border border-brand-100">
|
||||||
|
<p className="text-sm text-brand-800 font-medium">Option : <span className="font-bold">{selectedPlan.name}</span></p>
|
||||||
|
<p className="text-3xl font-black text-brand-900 mt-1">{selectedPlan.priceXOF.toLocaleString()} F CFA</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setPaymentMethod('mobile_money')}
|
||||||
|
className={`w-full flex items-center p-4 border-2 rounded-xl transition-all ${paymentMethod === 'mobile_money' ? 'border-brand-500 bg-brand-50/50' : 'border-gray-100 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
<div className="h-10 w-10 bg-orange-100 rounded-xl flex items-center justify-center text-orange-600 mr-4"><Smartphone /></div>
|
||||||
|
<div className="text-left flex-1">
|
||||||
|
<p className="font-bold text-gray-900">Mobile Money</p>
|
||||||
|
<p className="text-xs text-gray-500">Orange, MTN, Wave, Moov</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setPaymentMethod('card')}
|
||||||
|
className={`w-full flex items-center p-4 border-2 rounded-xl transition-all ${paymentMethod === 'card' ? 'border-brand-500 bg-brand-50/50' : 'border-gray-100 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
<div className="h-10 w-10 bg-blue-100 rounded-xl flex items-center justify-center text-blue-600 mr-4"><CreditCard /></div>
|
||||||
|
<div className="text-left flex-1">
|
||||||
|
<p className="font-bold text-gray-900">Carte Bancaire</p>
|
||||||
|
<p className="text-xs text-gray-500">Visa, Mastercard</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={!paymentMethod}
|
||||||
|
onClick={handlePayment}
|
||||||
|
className="w-full mt-8 bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 disabled:opacity-50 transition-all"
|
||||||
|
>
|
||||||
|
Payer maintenant
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{paymentStep === 'processing' && (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<Loader className="h-14 w-14 text-brand-600 animate-spin mx-auto mb-6" />
|
||||||
|
<p className="text-xl font-bold text-gray-900">Sécurisation du paiement...</p>
|
||||||
|
<p className="text-gray-500 mt-2">N'interrompez pas la transaction.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{paymentStep === 'success' && (
|
||||||
|
<div className="py-8 text-center animate-in zoom-in duration-300">
|
||||||
|
<div className="mx-auto flex items-center justify-center h-20 w-20 rounded-full bg-green-100 mb-6">
|
||||||
|
<ShieldCheck className="h-12 w-12 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-gray-900">Paiement Réussi !</h3>
|
||||||
|
<p className="text-gray-500 mt-3 mb-8">
|
||||||
|
{selectedPlan.type === 'POST_EVENT' || selectedPlan.type === 'POST_NEWS'
|
||||||
|
? "Votre paiement a été validé. Vous pouvez maintenant remplir le formulaire de votre publication."
|
||||||
|
: `Votre service ${selectedPlan.name} a été activé avec succès.`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{selectedPlan.type === 'POST_EVENT' || selectedPlan.type === 'POST_NEWS' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsPaymentModalOpen(false);
|
||||||
|
if (selectedPlan.type === 'POST_EVENT') setShowEventModal(true);
|
||||||
|
if (selectedPlan.type === 'POST_NEWS') setShowNewsModal(true);
|
||||||
|
}}
|
||||||
|
className="w-full bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{selectedPlan.type === 'POST_EVENT' ? <Calendar className="w-5 h-5" /> : <Newspaper className="w-5 h-5" />}
|
||||||
|
Remplir le formulaire
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={closePayment} className="w-full bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 transition-all">Retourner au site</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* EVENT FORM MODAL */}
|
||||||
|
<CreateEventModal
|
||||||
|
isOpen={showEventModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowEventModal(false);
|
||||||
|
closePayment();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* NEWS FORM MODAL */}
|
||||||
|
<CreateNewsModal
|
||||||
|
isOpen={showNewsModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowNewsModal(false);
|
||||||
|
closePayment();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OneShotPricingSection;
|
||||||
229
components/dashboard/CreateEventModal.tsx
Normal file
229
components/dashboard/CreateEventModal.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useTransition } from 'react';
|
||||||
|
import { X, Calendar, MapPin, Link as LinkIcon, Image as ImageIcon, AlertTriangle, Loader2 } from 'lucide-react';
|
||||||
|
import { createEvent } from '@/app/actions/events';
|
||||||
|
import { uploadImage } from '@/app/actions/upload';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface CreateEventModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateEventModal({ isOpen, onClose }: CreateEventModalProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: formData.get('title') as string,
|
||||||
|
description: formData.get('description') as string,
|
||||||
|
date: formData.get('date') as string,
|
||||||
|
location: formData.get('location') as string,
|
||||||
|
thumbnailUrl: imagePreview || "https://picsum.photos/800/400?random=event",
|
||||||
|
link: formData.get('link') as string || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.title || !data.description || !data.date || !data.location) {
|
||||||
|
toast.error("Veuillez remplir tous les champs obligatoires");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createEvent(data);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Événement soumis avec succès !");
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const result = await uploadImage(formData);
|
||||||
|
if (result.success && result.url) {
|
||||||
|
setImagePreview(result.url);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de l'upload");
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
||||||
|
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-brand-50/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-brand-100 rounded-xl text-brand-600">
|
||||||
|
<Calendar className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Publier un événement</h2>
|
||||||
|
<p className="text-xs text-gray-500">Partagez votre actualité avec la communauté</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
|
<X className="w-6 h-6 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6 max-h-[80vh] overflow-y-auto">
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="flex gap-4 p-4 bg-amber-50 border border-amber-200 rounded-2xl">
|
||||||
|
<div className="shrink-0 p-2 bg-amber-100 rounded-xl text-amber-600">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-amber-800">Information importante</h4>
|
||||||
|
<p className="text-xs text-amber-700 leading-relaxed">
|
||||||
|
Votre événement sera soumis à la validation de notre équipe de modération avant d'être publié sur la plateforme (généralement sous 24h).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Titre de l'événement *</label>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
placeholder="Ex: Conférence sur l'Agrotech"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Date *</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
name="date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Lieu *</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
name="location"
|
||||||
|
required
|
||||||
|
placeholder="Ex: Abidjan, Côte d'Ivoire"
|
||||||
|
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Description *</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
placeholder="Présentez votre événement en quelques lignes..."
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Affiche / Photo de l'événement</label>
|
||||||
|
<div
|
||||||
|
className={`relative border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-8 transition-all ${
|
||||||
|
imagePreview ? 'border-brand-500 bg-brand-50' : 'border-gray-200 hover:border-brand-300 bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{imagePreview ? (
|
||||||
|
<div className="relative w-full aspect-video rounded-lg overflow-hidden shadow-inner">
|
||||||
|
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImagePreview(null)}
|
||||||
|
className="absolute top-2 right-2 p-1 bg-white/80 rounded-full hover:bg-white text-gray-600 shadow-lg"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">PNG, JPG, JPEG jusqu'à 5Mo</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Lien externe (facultatif)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<LinkIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
name="link"
|
||||||
|
placeholder="https://votre-billetterie.com"
|
||||||
|
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-6 py-4 rounded-2xl font-bold text-gray-600 hover:bg-gray-100 transition-all"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || uploading}
|
||||||
|
className="flex-[2] bg-brand-600 hover:bg-brand-700 disabled:bg-gray-200 disabled:cursor-not-allowed text-white px-6 py-4 rounded-2xl font-bold shadow-xl shadow-brand-200 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Soumission...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Calendar className="w-5 h-5" />
|
||||||
|
Soumettre l'événement
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
components/dashboard/CreateNewsModal.tsx
Normal file
216
components/dashboard/CreateNewsModal.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useTransition } from 'react';
|
||||||
|
import { X, Newspaper, User, Image as ImageIcon, AlertTriangle, Loader2, AlignLeft } from 'lucide-react';
|
||||||
|
import { createNews } from '@/app/actions/news';
|
||||||
|
import { uploadImage } from '@/app/actions/upload';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface CreateNewsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateNewsModal({ isOpen, onClose }: CreateNewsModalProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: formData.get('title') as string,
|
||||||
|
excerpt: formData.get('excerpt') as string,
|
||||||
|
content: formData.get('content') as string,
|
||||||
|
author: formData.get('author') as string,
|
||||||
|
imageUrl: imagePreview || "https://picsum.photos/800/400?random=news",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.title || !data.excerpt || !data.content || !data.author) {
|
||||||
|
toast.error("Veuillez remplir tous les champs obligatoires");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createNews(data);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Actualité soumise avec succès !");
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const result = await uploadImage(formData);
|
||||||
|
if (result.success && result.url) {
|
||||||
|
setImagePreview(result.url);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de l'upload");
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
||||||
|
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-indigo-50/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-indigo-100 rounded-xl text-indigo-600">
|
||||||
|
<Newspaper className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Publier une actualité</h2>
|
||||||
|
<p className="text-xs text-gray-500">Mettez en avant vos succès et nouveautés</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
|
<X className="w-6 h-6 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6 max-h-[80vh] overflow-y-auto">
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="flex gap-4 p-4 bg-amber-50 border border-amber-200 rounded-2xl">
|
||||||
|
<div className="shrink-0 p-2 bg-amber-100 rounded-xl text-amber-600">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-amber-800">Information importante</h4>
|
||||||
|
<p className="text-xs text-amber-700 leading-relaxed">
|
||||||
|
Votre article sera soumis à la validation de notre équipe de modération avant d'être publié dans la section Actualités.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Titre de l'article *</label>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
placeholder="Ex: Lancement de notre nouvelle gamme de produits"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Auteur *</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
name="author"
|
||||||
|
required
|
||||||
|
placeholder="Votre nom ou nom d'entreprise"
|
||||||
|
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Résumé court (Excerpt) *</label>
|
||||||
|
<div className="relative">
|
||||||
|
<AlignLeft className="absolute left-4 top-4 w-4 h-4 text-gray-400" />
|
||||||
|
<textarea
|
||||||
|
name="excerpt"
|
||||||
|
required
|
||||||
|
rows={2}
|
||||||
|
placeholder="Un court résumé qui apparaîtra dans la liste des articles..."
|
||||||
|
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Contenu de l'article *</label>
|
||||||
|
<textarea
|
||||||
|
name="content"
|
||||||
|
required
|
||||||
|
rows={6}
|
||||||
|
placeholder="Racontez votre histoire..."
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-1">Image de couverture</label>
|
||||||
|
<div
|
||||||
|
className={`relative border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-8 transition-all ${
|
||||||
|
imagePreview ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-indigo-300 bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{imagePreview ? (
|
||||||
|
<div className="relative w-full aspect-video rounded-lg overflow-hidden shadow-inner">
|
||||||
|
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImagePreview(null)}
|
||||||
|
className="absolute top-2 right-2 p-1 bg-white/80 rounded-full hover:bg-white text-gray-600 shadow-lg"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">PNG, JPG, JPEG jusqu'à 5Mo</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-6 py-4 rounded-2xl font-bold text-gray-600 hover:bg-gray-100 transition-all"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || uploading}
|
||||||
|
className="flex-[2] bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-200 disabled:cursor-not-allowed text-white px-6 py-4 rounded-2xl font-bold shadow-xl shadow-indigo-200 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Soumission...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Newspaper className="w-5 h-5" />
|
||||||
|
Soumettre l'actualité
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,16 +53,33 @@ const DashboardMessages = ({ onMessagesRead, initialConversationId }: DashboardM
|
|||||||
const [isMobileListVisible, setIsMobileListVisible] = useState(true);
|
const [isMobileListVisible, setIsMobileListVisible] = useState(true);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const initialSelectionDone = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConversations();
|
if (user?.id) {
|
||||||
}, []);
|
fetchConversations();
|
||||||
|
|
||||||
useEffect(() => {
|
// Polling automatique de la liste des conversations toutes les 5 secondes
|
||||||
if (selectedConversation) {
|
const interval = setInterval(() => {
|
||||||
fetchMessages(selectedConversation.id);
|
fetchConversations();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [selectedConversation]);
|
}, [user?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedConversation?.id) {
|
||||||
|
fetchMessages(selectedConversation.id);
|
||||||
|
|
||||||
|
// Polling des messages toutes les 2.5 secondes pour une messagerie instantanée fluide
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchMessages(selectedConversation.id);
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [selectedConversation?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
@@ -81,12 +98,13 @@ const DashboardMessages = ({ onMessagesRead, initialConversationId }: DashboardM
|
|||||||
if (!data.error) {
|
if (!data.error) {
|
||||||
setConversations(data);
|
setConversations(data);
|
||||||
|
|
||||||
// Handle initial auto-selection
|
// Handle initial auto-selection une seule fois
|
||||||
if (initialConversationId) {
|
if (initialConversationId && !initialSelectionDone.current) {
|
||||||
const found = data.find((c: Conversation) => c.id === initialConversationId);
|
const found = data.find((c: Conversation) => c.id === initialConversationId);
|
||||||
if (found) {
|
if (found) {
|
||||||
setSelectedConversation(found);
|
setSelectedConversation(found);
|
||||||
setIsMobileListVisible(false);
|
setIsMobileListVisible(false);
|
||||||
|
initialSelectionDone.current = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,10 +122,18 @@ const DashboardMessages = ({ onMessagesRead, initialConversationId }: DashboardM
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.error) {
|
if (!data.error) {
|
||||||
setMessages(data);
|
setMessages(prev => {
|
||||||
// After fetching messages (which marks them as read in the backend),
|
const hasChanged = prev.length !== data.length ||
|
||||||
// notify the parent to refresh the unread count badge
|
(prev.length > 0 && data.length > 0 && prev[prev.length - 1].id !== data[data.length - 1].id);
|
||||||
if (onMessagesRead) onMessagesRead();
|
|
||||||
|
if (hasChanged) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onMessagesRead) onMessagesRead();
|
||||||
|
}, 0);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching messages:', error);
|
console.error('Error fetching messages:', error);
|
||||||
|
|||||||
38
doc/admin/gestion_utilisateurs.md
Normal file
38
doc/admin/gestion_utilisateurs.md
Normal 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**.
|
||||||
35
doc/admin/guide_maintenance.md
Normal file
35
doc/admin/guide_maintenance.md
Normal 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.
|
||||||
44
doc/admin/specs_fonctionnelles.md
Normal file
44
doc/admin/specs_fonctionnelles.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Spécifications Fonctionnelles - Panel Administration
|
||||||
|
|
||||||
|
## 1. Vue d'ensemble
|
||||||
|
Le panel d'administration permet de piloter l'ensemble de la plateforme Afrohub, de modérer les contenus et de gérer la configuration globale.
|
||||||
|
|
||||||
|
## 2. Tableau de Bord (Dashboard)
|
||||||
|
- Statistiques clés : Nombre d'utilisateurs, entreprises, avis en attente.
|
||||||
|
- Métriques d'audience (via le menu Metrics).
|
||||||
|
- Visualisation géographique des entreprises.
|
||||||
|
|
||||||
|
## 3. Gestion des Utilisateurs
|
||||||
|
### 3.1. Entrepreneurs
|
||||||
|
- Validation des boutiques (Certification).
|
||||||
|
- Modification des forfaits (Starter, Booster, Empire).
|
||||||
|
- Gestion de la mise en avant (Slides Home).
|
||||||
|
- Suspension de boutique ou de compte complet.
|
||||||
|
- Suppression de compte avec choix du motif et du délai (0, 7, 15, 30 jours).
|
||||||
|
|
||||||
|
### 3.2. Visiteurs
|
||||||
|
- Consultation des informations de contact.
|
||||||
|
- Vérification manuelle de l'email.
|
||||||
|
- Suspension et suppression de compte.
|
||||||
|
|
||||||
|
## 4. Modération des Contenus
|
||||||
|
- **Avis** : Approbation ou rejet des avis laissés par les utilisateurs.
|
||||||
|
- **Signalements** : Traitement des messages signalés dans la messagerie.
|
||||||
|
- **Commentaires** : Gestion des commentaires sur les articles.
|
||||||
|
|
||||||
|
## 5. Gestion éditoriale
|
||||||
|
- **Actualités** : Création, édition et suppression d'articles.
|
||||||
|
- **Interviews** : Gestion des entretiens avec les entrepreneurs.
|
||||||
|
- **Événements** : Publication et gestion de l'agenda.
|
||||||
|
- **Slides** : Configuration du carrousel de la page d'accueil (Boutiques ou Custom).
|
||||||
|
|
||||||
|
## 6. Configuration du Site
|
||||||
|
- Informations de contact (Email, Téléphone, Adresse).
|
||||||
|
- Liens réseaux sociaux.
|
||||||
|
- Gestion des documents légaux (CGU, CGV, Confidentialité).
|
||||||
|
- Personnalisation des templates d'emails (Vérification, Mot de passe, Suppression, Réactivation).
|
||||||
|
- Gestion des taxonomies (Catégories d'entreprises, Pays).
|
||||||
|
|
||||||
|
## 7. Automatisation (Cron)
|
||||||
|
- Nettoyage automatique des comptes marqués pour suppression après expiration du délai.
|
||||||
|
- URL sécurisée : `/api/cron/cleanup-users?key=SECRET`.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user