Compare commits
79 Commits
0460a1586f
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 7db35361d9 | |||
| b6f828357d | |||
| 9846efd03e | |||
| 87b13dce13 | |||
| 13b1730f6f | |||
| 5176e51e21 | |||
| f4f303b703 | |||
| 7174736680 | |||
| 889d3967d2 | |||
| 94de36e937 | |||
| 15700036e5 | |||
| fe7acfa927 | |||
| c6c6a05c47 | |||
| f64b8ba53c | |||
| 3567e5d224 | |||
| 863e8fdc4a | |||
| cd862e503d | |||
| 343f29459b | |||
|
|
4db8271ecf | ||
|
|
e6d6770c26 | ||
| 371ed17867 | |||
| f6e3c1bb8c | |||
| 6acd5ebe2e | |||
| 6a42c52b58 | |||
| 72c24e0cc6 | |||
| 67df2d95e9 | |||
| 4aedcdb45a | |||
| bac36efb94 | |||
| d1b32cd401 | |||
| 2148f7281e | |||
| 7b015cb758 | |||
| bb464cf1c5 | |||
| 19b405d9f2 | |||
| a37b34f758 | |||
| 13c88dd4f7 | |||
| 52a9d47c30 | |||
| f93d70c447 | |||
| c73763be8a | |||
| 268e14f369 | |||
| e1aa0f076a | |||
| 3d7cb5f4d1 | |||
| 2d9d707251 | |||
| 5720661f8d | |||
| 1b6d409319 | |||
| 75ae9c5f3d | |||
| 63b5af4868 | |||
| a23c3bf780 | |||
| 16d66c0456 | |||
| 5f421b418e | |||
| 0e72867d4c | |||
| 2122075801 | |||
| 0f526c851a | |||
| 79a9cbffe9 | |||
| f450f8ee03 | |||
| 9c003d1b7d | |||
| 4e881bcbe6 | |||
| 81b068e5c9 | |||
| b55b15a2aa | |||
| 159f1c1709 | |||
| a814c7b577 | |||
| 38caf36bee | |||
|
|
ef7ac122f8 | ||
|
|
7900c9d5be | ||
| b89dc62408 | |||
| 792a951e02 | |||
| 27b35104b9 | |||
| d932186727 | |||
| 281fb764c4 | |||
|
|
31fa2fcde3 | ||
|
|
a551fe6bcc | ||
|
|
668b6bd8b8 | ||
|
|
d1e551bcca | ||
| 3c58e60b33 | |||
| 11678e0b33 | |||
| 1a66c46389 | |||
| e6310f30de | |||
| 88e4c13b9f | |||
| 33ec11d6db | |||
| 12e48a738c |
5
.env
5
.env
@@ -1,5 +1,8 @@
|
|||||||
# Connexion locale via le Docker Compose fourni :
|
# Connexion locale via le Docker Compose fourni :
|
||||||
DATABASE_URL="postgresql://admin:adminpassword@localhost:5432/afrohub_dev?schema=public"
|
#DATABASE_URL="postgresql://admin:adminpassword@localhost:5432/afrohub_dev?schema=public"
|
||||||
|
DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?schema=public"
|
||||||
|
|
||||||
# Exemple avec Supabase ou Neon (si base distante)
|
# Exemple avec Supabase ou Neon (si base distante)
|
||||||
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
||||||
|
# Environnement : "development" = accents rouges, "production" = accents normaux
|
||||||
|
NEXT_PUBLIC_APP_ENV=development
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
name: Build and Push App
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# FORCE l'image ici pour être sûr d'avoir l'outil Docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.TOKEN }}" | docker login git.home.arrondeau.fr -u ${{ gitea.actor }} --password-stdin
|
|
||||||
|
|
||||||
- name: Build and Push Docker Images
|
|
||||||
run: |
|
|
||||||
# Build and Push FRONT
|
|
||||||
docker build -t git.home.arrondeau.fr/${{ gitea.repository }}:latest .
|
|
||||||
docker push git.home.arrondeau.fr/${{ gitea.repository }}:latest
|
|
||||||
|
|
||||||
# Build and Push ADMIN
|
|
||||||
docker build -t git.home.arrondeau.fr/${{ gitea.repository }}-admin:latest ./admin
|
|
||||||
docker push git.home.arrondeau.fr/${{ gitea.repository }}-admin:latest
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
turbopack: {
|
||||||
|
root: path.join(__dirname, ".."),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
768
admin/package-lock.json
generated
768
admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,9 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.7.0",
|
"@prisma/adapter-pg": "6.19.3",
|
||||||
"@prisma/client": "^7.7.0",
|
"@prisma/client": "6.19.3",
|
||||||
"@prisma/config": "^7.7.0",
|
"@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",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"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": "^7.7.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",
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { config } from 'dotenv';
|
|
||||||
|
|
||||||
// Load .env then override with .env.local for Next.js compatibility
|
|
||||||
config();
|
|
||||||
config({ path: '.env.local', override: true });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prisma 7 Configuration
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
datasource: {
|
|
||||||
url: process.env.DATABASE_URL,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
BIN
admin/prisma/migrations/0_init/migration.sql
Normal file
BIN
admin/prisma/migrations/0_init/migration.sql
Normal file
Binary file not shown.
459
admin/prisma/migrations/20260427214502_init/migration.sql
Normal file
459
admin/prisma/migrations/20260427214502_init/migration.sql
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RatingStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ReportStatus" AS ENUM ('PENDING', 'RESOLVED', 'DISMISSED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserRole" AS ENUM ('VISITOR', 'ENTREPRENEUR', 'ADMIN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Plan" AS ENUM ('STARTER', 'BOOSTER', 'EMPIRE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OfferType" AS ENUM ('PRODUCT', 'SERVICE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "InterviewType" AS ENUM ('VIDEO', 'ARTICLE');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"role" "UserRole" NOT NULL DEFAULT 'VISITOR',
|
||||||
|
"avatar" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"bio" TEXT,
|
||||||
|
"location" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"isSuspended" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"suspensionReason" TEXT,
|
||||||
|
"countryId" TEXT,
|
||||||
|
"resetToken" TEXT,
|
||||||
|
"resetTokenExpiry" TIMESTAMP(3),
|
||||||
|
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"verificationToken" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Business" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"location" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"logoUrl" TEXT NOT NULL,
|
||||||
|
"videoUrl" TEXT,
|
||||||
|
"contactEmail" TEXT NOT NULL,
|
||||||
|
"contactPhone" TEXT,
|
||||||
|
"socialLinks" JSONB,
|
||||||
|
"verified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"viewCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"rating" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||||
|
"ratingCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"tags" TEXT[],
|
||||||
|
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"founderName" TEXT,
|
||||||
|
"founderImageUrl" TEXT,
|
||||||
|
"keyMetric" TEXT,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"slug" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"showEmail" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showPhone" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showSocials" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"websiteUrl" TEXT,
|
||||||
|
"isSuspended" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"suspensionReason" TEXT,
|
||||||
|
"plan" "Plan" NOT NULL DEFAULT 'STARTER',
|
||||||
|
"city" TEXT,
|
||||||
|
"countryId" TEXT,
|
||||||
|
"coverUrl" TEXT,
|
||||||
|
"coverPosition" TEXT DEFAULT '50% 50%',
|
||||||
|
"coverZoom" DOUBLE PRECISION DEFAULT 1.0,
|
||||||
|
"suggestedCategory" TEXT,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Business_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BusinessCategory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"icon" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BusinessCategory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Country" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"flag" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Country_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PricingPlan" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tier" "Plan" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"priceXOF" TEXT NOT NULL,
|
||||||
|
"priceEUR" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"features" TEXT[],
|
||||||
|
"offerLimit" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"recommended" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"color" TEXT NOT NULL DEFAULT 'gray',
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"yearlyPriceEUR" TEXT,
|
||||||
|
"yearlyPriceXOF" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "PricingPlan_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Offer" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"type" "OfferType" NOT NULL,
|
||||||
|
"price" DOUBLE PRECISION NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'XOF',
|
||||||
|
"description" TEXT,
|
||||||
|
"imageUrl" TEXT NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Offer_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BlogPost" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"excerpt" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"author" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"imageUrl" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"metaDescription" TEXT,
|
||||||
|
"metaTitle" TEXT,
|
||||||
|
"slug" TEXT,
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
|
||||||
|
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Interview" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"guestName" TEXT NOT NULL,
|
||||||
|
"companyName" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"type" "InterviewType" NOT NULL,
|
||||||
|
"thumbnailUrl" TEXT NOT NULL,
|
||||||
|
"videoUrl" TEXT,
|
||||||
|
"content" TEXT,
|
||||||
|
"excerpt" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"duration" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"metaDescription" TEXT,
|
||||||
|
"metaTitle" TEXT,
|
||||||
|
"slug" TEXT,
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
|
||||||
|
CONSTRAINT "Interview_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Comment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AnalyticsEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"path" TEXT NOT NULL,
|
||||||
|
"label" TEXT,
|
||||||
|
"value" DOUBLE PRECISION,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"browser" TEXT,
|
||||||
|
"city" TEXT,
|
||||||
|
"country" TEXT,
|
||||||
|
"device" TEXT,
|
||||||
|
"ip" TEXT,
|
||||||
|
"os" TEXT,
|
||||||
|
"referrer" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Rating" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"comment" TEXT,
|
||||||
|
"reply" TEXT,
|
||||||
|
"replyAt" TIMESTAMP(3),
|
||||||
|
"status" "RatingStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
|
||||||
|
CONSTRAINT "Rating_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Conversation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Conversation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConversationParticipant" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"conversationId" TEXT NOT NULL,
|
||||||
|
"isArchived" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationParticipant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Message" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"senderId" TEXT NOT NULL,
|
||||||
|
"conversationId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"read" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MessageReport" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"reason" TEXT,
|
||||||
|
"messageId" TEXT NOT NULL,
|
||||||
|
"reporterId" TEXT NOT NULL,
|
||||||
|
"status" "ReportStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MessageReport_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SiteSetting" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"siteName" TEXT NOT NULL DEFAULT 'Afrohub',
|
||||||
|
"siteSlogan" TEXT NOT NULL DEFAULT 'La plateforme de référence pour l''entrepreneuriat africain.',
|
||||||
|
"contactEmail" TEXT NOT NULL DEFAULT 'support@afrohub.com',
|
||||||
|
"contactPhone" TEXT DEFAULT '+225 00 00 00 00 00',
|
||||||
|
"address" TEXT DEFAULT 'Abidjan, Côte d''Ivoire',
|
||||||
|
"facebookUrl" TEXT,
|
||||||
|
"twitterUrl" TEXT,
|
||||||
|
"instagramUrl" TEXT,
|
||||||
|
"linkedinUrl" TEXT,
|
||||||
|
"footerText" TEXT DEFAULT '© 2025 Afrohub. Tous droits réservés.',
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"homeBlogCount" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"homeBlogShow" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"homeBlogSubtitle" TEXT NOT NULL DEFAULT 'Toutes les actualités de l''entrepreneuriat africain.',
|
||||||
|
"homeBlogTitle" TEXT NOT NULL DEFAULT 'Derniers Articles',
|
||||||
|
"homeBlogCategories" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"homeBlogIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"homeBlogSelection" TEXT NOT NULL DEFAULT 'latest',
|
||||||
|
"homeCategories" TEXT[] DEFAULT ARRAY['Technologie & IT', 'Agriculture & Agrobusiness', 'Mode & Textile', 'Cosmétique & Beauté']::TEXT[],
|
||||||
|
"resetPasswordSubject" TEXT NOT NULL DEFAULT 'Réinitialisation de votre mot de passe - Afrohub',
|
||||||
|
"resetPasswordTemplate" TEXT NOT NULL DEFAULT '<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;"><h2 style="color: #0d9488; text-align: center;">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style="text-align: center; margin: 30px 0;"><a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n''avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;"><p style="font-size: 12px; color: #6b7280; text-align: center;">© 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;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BlogPost" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Interview" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BlogPost" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Interview" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
|
||||||
3
admin/prisma/migrations/migration_lock.toml
Normal file
3
admin/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -5,104 +5,128 @@ generator client {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
role UserRole @default(VISITOR)
|
role UserRole @default(VISITOR)
|
||||||
avatar String?
|
avatar String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
bio String?
|
bio String?
|
||||||
location String?
|
location String?
|
||||||
countryId String?
|
phone String?
|
||||||
phone String?
|
isSuspended Boolean @default(false)
|
||||||
isSuspended Boolean @default(false)
|
suspensionReason String?
|
||||||
suspensionReason String?
|
countryId String?
|
||||||
businesses Business?
|
resetToken String? @unique
|
||||||
comments Comment[]
|
resetTokenExpiry DateTime?
|
||||||
conversations ConversationParticipant[]
|
emailVerified Boolean @default(false)
|
||||||
sentMessages Message[]
|
verificationToken String? @unique
|
||||||
reports MessageReport[]
|
businesses Business?
|
||||||
ratings Rating[]
|
comments Comment[]
|
||||||
country Country? @relation(fields: [countryId], references: [id])
|
conversations ConversationParticipant[]
|
||||||
|
favorites Favorite[]
|
||||||
|
sentMessages Message[]
|
||||||
|
reports MessageReport[]
|
||||||
|
ratings Rating[]
|
||||||
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Business {
|
model Business {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
category String
|
category String
|
||||||
location String // Legacy location string
|
location String
|
||||||
countryId String?
|
description String
|
||||||
city String?
|
logoUrl String
|
||||||
description String
|
videoUrl String?
|
||||||
logoUrl String
|
contactEmail String
|
||||||
videoUrl String?
|
contactPhone String?
|
||||||
contactEmail String
|
socialLinks Json?
|
||||||
contactPhone String?
|
verified Boolean @default(false)
|
||||||
socialLinks Json?
|
viewCount Int @default(0)
|
||||||
verified Boolean @default(false)
|
rating Float @default(0.0)
|
||||||
viewCount Int @default(0)
|
ratingCount Int @default(0)
|
||||||
rating Float @default(0.0)
|
tags String[]
|
||||||
ratingCount Int @default(0)
|
isFeatured Boolean @default(false)
|
||||||
tags String[]
|
founderName String?
|
||||||
isFeatured Boolean @default(false)
|
founderImageUrl String?
|
||||||
founderName String?
|
keyMetric String?
|
||||||
founderImageUrl String?
|
ownerId String @unique
|
||||||
keyMetric String?
|
createdAt DateTime @default(now())
|
||||||
ownerId String @unique
|
updatedAt DateTime @updatedAt
|
||||||
createdAt DateTime @default(now())
|
slug String? @unique
|
||||||
updatedAt DateTime @updatedAt
|
isActive Boolean @default(false)
|
||||||
slug String? @unique
|
showEmail Boolean @default(true)
|
||||||
isActive Boolean @default(false)
|
showPhone Boolean @default(true)
|
||||||
showEmail Boolean @default(true)
|
showSocials Boolean @default(true)
|
||||||
showPhone Boolean @default(true)
|
websiteUrl String?
|
||||||
showSocials Boolean @default(true)
|
isSuspended Boolean @default(false)
|
||||||
websiteUrl String?
|
suspensionReason String?
|
||||||
isSuspended Boolean @default(false)
|
plan Plan @default(STARTER)
|
||||||
suspensionReason String?
|
city String?
|
||||||
plan Plan @default(STARTER)
|
countryId String?
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
coverUrl String?
|
||||||
comments Comment[]
|
coverPosition String? @default("50% 50%")
|
||||||
conversations Conversation[]
|
coverZoom Float? @default(1.0)
|
||||||
offers Offer[]
|
suggestedCategory String?
|
||||||
ratings Rating[]
|
categoryId String?
|
||||||
country Country? @relation(fields: [countryId], references: [id])
|
metaDescription String?
|
||||||
|
metaTitle String?
|
||||||
|
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
|
||||||
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
|
comments Comment[]
|
||||||
|
conversations Conversation[]
|
||||||
|
favorites Favorite[]
|
||||||
|
homeSlides HomeSlide[]
|
||||||
|
offers Offer[]
|
||||||
|
ratings Rating[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model BusinessCategory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
icon String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
businesses Business[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Country {
|
model Country {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
code String @unique // ISO alpha-2
|
code String @unique
|
||||||
flag String? // Emoji or URL
|
flag String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
businesses Business[]
|
createdAt DateTime @default(now())
|
||||||
users User[]
|
updatedAt DateTime @updatedAt
|
||||||
createdAt DateTime @default(now())
|
description String?
|
||||||
updatedAt DateTime @updatedAt
|
businesses Business[]
|
||||||
}
|
users User[]
|
||||||
|
|
||||||
enum Plan {
|
|
||||||
STARTER
|
|
||||||
BOOSTER
|
|
||||||
EMPIRE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model PricingPlan {
|
model PricingPlan {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tier Plan @unique
|
tier Plan @unique
|
||||||
name String
|
name String
|
||||||
priceXOF String
|
priceXOF String
|
||||||
priceEUR String
|
priceEUR String
|
||||||
description String
|
description String
|
||||||
features String[]
|
features String[]
|
||||||
offerLimit Int @default(1)
|
offerLimit Int @default(1)
|
||||||
recommended Boolean @default(false)
|
recommended Boolean @default(false)
|
||||||
color String @default("gray")
|
color String @default("gray")
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
yearlyPriceEUR String?
|
||||||
|
yearlyPriceXOF String?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Offer {
|
model Offer {
|
||||||
@@ -121,25 +145,41 @@ model Offer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model BlogPost {
|
model BlogPost {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
slug String? @unique
|
|
||||||
excerpt String
|
excerpt String
|
||||||
content String
|
content String
|
||||||
author String
|
author String
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
imageUrl String
|
imageUrl String
|
||||||
tags String[] @default([])
|
createdAt DateTime @default(now())
|
||||||
metaTitle String?
|
updatedAt DateTime @updatedAt
|
||||||
metaDescription String?
|
metaDescription String?
|
||||||
createdAt DateTime @default(now())
|
metaTitle String?
|
||||||
updatedAt DateTime @updatedAt
|
slug String? @unique
|
||||||
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
}
|
||||||
|
|
||||||
|
model HomeSlide {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type HomeSlideType @default(CUSTOM)
|
||||||
|
businessId String?
|
||||||
|
title String?
|
||||||
|
subtitle String?
|
||||||
|
imageUrl String?
|
||||||
|
linkUrl String?
|
||||||
|
order Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
business Business? @relation(fields: [businessId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Interview {
|
model Interview {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
slug String? @unique
|
|
||||||
guestName String
|
guestName String
|
||||||
companyName String
|
companyName String
|
||||||
role String
|
role String
|
||||||
@@ -150,11 +190,14 @@ model Interview {
|
|||||||
excerpt String
|
excerpt String
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
duration String?
|
duration String?
|
||||||
tags String[] @default([])
|
|
||||||
metaTitle String?
|
|
||||||
metaDescription String?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
metaDescription String?
|
||||||
|
metaTitle String?
|
||||||
|
slug String? @unique
|
||||||
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
@@ -175,40 +218,34 @@ model AnalyticsEvent {
|
|||||||
label String?
|
label String?
|
||||||
value Float?
|
value Float?
|
||||||
metadata Json?
|
metadata Json?
|
||||||
ip String?
|
createdAt DateTime @default(now())
|
||||||
country String?
|
|
||||||
city String?
|
|
||||||
browser String?
|
browser String?
|
||||||
os String?
|
city String?
|
||||||
|
country String?
|
||||||
device String?
|
device String?
|
||||||
|
ip String?
|
||||||
|
os String?
|
||||||
referrer String?
|
referrer String?
|
||||||
userId String?
|
userId String?
|
||||||
createdAt DateTime @default(now())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Rating {
|
model Rating {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
value Int
|
value Int
|
||||||
|
userId String
|
||||||
|
businessId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
comment String?
|
comment String?
|
||||||
reply String?
|
reply String?
|
||||||
replyAt DateTime?
|
replyAt DateTime?
|
||||||
status RatingStatus @default(PENDING)
|
status RatingStatus @default(PENDING)
|
||||||
userId String
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
businessId String
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([userId, businessId])
|
@@unique([userId, businessId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RatingStatus {
|
|
||||||
PENDING
|
|
||||||
APPROVED
|
|
||||||
REJECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
model Conversation {
|
model Conversation {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
businessId String
|
businessId String
|
||||||
@@ -253,6 +290,105 @@ model MessageReport {
|
|||||||
reporter User @relation(fields: [reporterId], 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 OneShotPlan {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type OneShotType @unique
|
||||||
|
name String
|
||||||
|
priceXOF Int @default(0)
|
||||||
|
priceEUR Int @default(0)
|
||||||
|
description String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OneShotType {
|
||||||
|
BUMP
|
||||||
|
POST_EVENT
|
||||||
|
POST_NEWS
|
||||||
|
AD_DIRECTORY
|
||||||
|
}
|
||||||
|
|
||||||
|
model LegalDocument {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type String @unique
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Event {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
date DateTime
|
||||||
|
location String
|
||||||
|
thumbnailUrl String
|
||||||
|
link String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
metaDescription String?
|
||||||
|
metaTitle String?
|
||||||
|
slug String? @unique
|
||||||
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Favorite {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
businessId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, businessId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HomeSlideType {
|
||||||
|
BUSINESS
|
||||||
|
CUSTOM
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContentStatus {
|
||||||
|
DRAFT
|
||||||
|
PENDING
|
||||||
|
PUBLISHED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RatingStatus {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
enum ReportStatus {
|
enum ReportStatus {
|
||||||
PENDING
|
PENDING
|
||||||
RESOLVED
|
RESOLVED
|
||||||
@@ -265,6 +401,12 @@ enum UserRole {
|
|||||||
ADMIN
|
ADMIN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Plan {
|
||||||
|
STARTER
|
||||||
|
BOOSTER
|
||||||
|
EMPIRE
|
||||||
|
}
|
||||||
|
|
||||||
enum OfferType {
|
enum OfferType {
|
||||||
PRODUCT
|
PRODUCT
|
||||||
SERVICE
|
SERVICE
|
||||||
@@ -274,41 +416,3 @@ enum InterviewType {
|
|||||||
VIDEO
|
VIDEO
|
||||||
ARTICLE
|
ARTICLE
|
||||||
}
|
}
|
||||||
|
|
||||||
model SiteSetting {
|
|
||||||
id String @id @default("singleton")
|
|
||||||
siteName String @default("Afrohub")
|
|
||||||
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
|
|
||||||
contactEmail String @default("support@afrohub.com")
|
|
||||||
contactPhone String? @default("+225 00 00 00 00 00")
|
|
||||||
address String? @default("Abidjan, Côte d'Ivoire")
|
|
||||||
facebookUrl String?
|
|
||||||
twitterUrl String?
|
|
||||||
instagramUrl String?
|
|
||||||
linkedinUrl String?
|
|
||||||
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
model LegalDocument {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
type String @unique // 'CGU' or 'CGV'
|
|
||||||
title String
|
|
||||||
content String @db.Text
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Event {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
title String
|
|
||||||
slug String? @unique
|
|
||||||
description String @db.Text
|
|
||||||
date DateTime
|
|
||||||
location String
|
|
||||||
thumbnailUrl String
|
|
||||||
link String?
|
|
||||||
tags String[] @default([])
|
|
||||||
metaTitle String?
|
|
||||||
metaDescription String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|||||||
BIN
admin/public/uploads/1777833997337-98dkw92.gif
Normal file
BIN
admin/public/uploads/1777833997337-98dkw92.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
admin/public/uploads/1777834233872-lz35qcr.jpg
Normal file
BIN
admin/public/uploads/1777834233872-lz35qcr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -3,6 +3,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
|
||||||
export async function createBlogPost(data: {
|
export async function createBlogPost(data: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,6 +15,8 @@ export async function createBlogPost(data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -23,10 +26,13 @@ export async function createBlogPost(data: {
|
|||||||
...data,
|
...data,
|
||||||
slug,
|
slug,
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
|
publishedAt: data.publishedAt || new Date(),
|
||||||
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/actualites");
|
||||||
return { success: true, data: post };
|
revalidatePath("/");
|
||||||
|
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);
|
||||||
return { success: false, error: "Erreur lors de la création de l'article" };
|
return { success: false, error: "Erreur lors de la création de l'article" };
|
||||||
@@ -43,6 +49,8 @@ export async function updateBlogPost(id: string, data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -55,8 +63,9 @@ export async function updateBlogPost(id: string, data: {
|
|||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/actualites");
|
||||||
return { success: true, data: post };
|
revalidatePath("/");
|
||||||
|
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);
|
||||||
return { success: false, error: "Erreur lors de la mise à jour" };
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
@@ -68,10 +77,22 @@ 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);
|
||||||
return { success: false, error: "Erreur lors de la suppression" };
|
return { success: false, error: "Erreur lors de la suppression" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBlogPosts() {
|
||||||
|
try {
|
||||||
|
return await prisma.blogPost.findMany({
|
||||||
|
orderBy: { date: 'desc' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch blog posts:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ export async function setFeaturedBusiness(id: string, data: {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to set featured business:", error);
|
console.error("Failed to set featured business:", error);
|
||||||
return { success: false, error: "Erreur lors de la mise à jour de l'entrepreneur du mois" };
|
return { success: false, error: "Erreur lors de la mise à jour de l'Afroshine" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,3 +79,23 @@ export async function getPendingVerificationCount() {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export async function updateBusinessSeo(id: string, data: {
|
||||||
|
metaTitle: string;
|
||||||
|
metaDescription: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
metaTitle: data.metaTitle,
|
||||||
|
metaDescription: data.metaDescription,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/users");
|
||||||
|
revalidatePath(`/annuaire/${id}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update business SEO:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour SEO" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
111
admin/src/app/actions/categories.ts
Normal file
111
admin/src/app/actions/categories.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function getCategories() {
|
||||||
|
try {
|
||||||
|
return await prisma.businessCategory.findMany({
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch categories:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategory(data: { name: string; slug: string; icon?: string; suggestionId?: string }) {
|
||||||
|
try {
|
||||||
|
const { suggestionId, ...categoryData } = data;
|
||||||
|
|
||||||
|
const category = await prisma.businessCategory.create({
|
||||||
|
data: categoryData
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si c'est une conversion de suggestion, on lie automatiquement l'entreprise concernée
|
||||||
|
if (suggestionId) {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id: suggestionId },
|
||||||
|
data: {
|
||||||
|
category: category.name,
|
||||||
|
categoryId: category.id,
|
||||||
|
suggestedCategory: null // On nettoie le champ tampon car la suggestion est validée
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
revalidatePath("/categories");
|
||||||
|
revalidatePath("/entrepreneurs");
|
||||||
|
return { success: true, data: category };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create category:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création de la catégorie" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCategory(id: string, data: { name: string; slug: string; icon?: string; isActive: boolean }) {
|
||||||
|
try {
|
||||||
|
// 1. Récupérer l'ancien nom pour la migration
|
||||||
|
const oldCategory = await prisma.businessCategory.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { name: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Mettre à jour la catégorie
|
||||||
|
const category = await prisma.businessCategory.update({
|
||||||
|
where: { id },
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Si le nom a changé, mettre à jour tous les entrepreneurs qui l'utilisaient
|
||||||
|
if (oldCategory && oldCategory.name !== data.name) {
|
||||||
|
await prisma.business.updateMany({
|
||||||
|
where: { category: oldCategory.name },
|
||||||
|
data: { category: data.name }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
revalidatePath("/categories");
|
||||||
|
return { success: true, data: category };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update category:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategory(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.businessCategory.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
revalidatePath("/settings");
|
||||||
|
revalidatePath("/categories");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete category:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la suppression" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSuggestedCategories() {
|
||||||
|
try {
|
||||||
|
return await prisma.business.findMany({
|
||||||
|
where: {
|
||||||
|
suggestedCategory: { not: null },
|
||||||
|
category: 'Autre'
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
suggestedCategory: true,
|
||||||
|
createdAt: true
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch suggested categories:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,13 +14,14 @@ export async function getCountries() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addCountry(data: { name: string, code: string, flag?: string }) {
|
export async function addCountry(data: { name: string, code: string, flag?: string, description?: string }) {
|
||||||
try {
|
try {
|
||||||
const country = await prisma.country.create({
|
const country = await prisma.country.create({
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
code: data.code.toUpperCase(),
|
code: data.code.toUpperCase(),
|
||||||
flag: data.flag || null,
|
flag: data.flag || null,
|
||||||
|
description: data.description || null,
|
||||||
isActive: true
|
isActive: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -58,3 +59,17 @@ export async function toggleCountryStatus(id: string, isActive: boolean) {
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateCountryDescription(id: string, description: string) {
|
||||||
|
try {
|
||||||
|
await prisma.country.update({
|
||||||
|
where: { id },
|
||||||
|
data: { description }
|
||||||
|
});
|
||||||
|
revalidatePath('/countries');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating country description:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la mise à jour.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
|
||||||
export async function createEvent(data: {
|
export async function createEvent(data: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,6 +16,8 @@ export async function createEvent(data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -24,6 +27,8 @@ export async function createEvent(data: {
|
|||||||
...data,
|
...data,
|
||||||
slug,
|
slug,
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
|
publishedAt: data.publishedAt || new Date(),
|
||||||
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/blog");
|
||||||
@@ -46,6 +51,8 @@ export async function updateEvent(id: string, data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { InterviewType } from "@prisma/client";
|
import { InterviewType, ContentStatus } from "@prisma/client";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
|
||||||
export async function createInterview(data: {
|
export async function createInterview(data: {
|
||||||
@@ -20,6 +20,8 @@ export async function createInterview(data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -29,6 +31,8 @@ export async function createInterview(data: {
|
|||||||
...data,
|
...data,
|
||||||
slug,
|
slug,
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
|
publishedAt: data.publishedAt || new Date(),
|
||||||
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/blog");
|
||||||
@@ -54,6 +58,8 @@ export async function updateInterview(id: string, data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ export async function updatePricingPlanDetail(id: string, data: any) {
|
|||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
priceXOF: data.priceXOF,
|
priceXOF: data.priceXOF,
|
||||||
|
yearlyPriceXOF: data.yearlyPriceXOF || null,
|
||||||
priceEUR: data.priceEUR,
|
priceEUR: data.priceEUR,
|
||||||
|
yearlyPriceEUR: data.yearlyPriceEUR || null,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
features: data.features, // Expected to be string[]
|
features: data.features, // Expected to be string[]
|
||||||
offerLimit: parseInt(data.offerLimit),
|
offerLimit: parseInt(data.offerLimit),
|
||||||
|
|||||||
@@ -20,7 +20,39 @@ export async function getSiteSettings() {
|
|||||||
twitterUrl: "",
|
twitterUrl: "",
|
||||||
instagramUrl: "",
|
instagramUrl: "",
|
||||||
linkedinUrl: "",
|
linkedinUrl: "",
|
||||||
footerText: "© 2025 Afrohub. Tous droits réservés."
|
footerText: "© 2025 Afrohub. Tous droits réservés.",
|
||||||
|
homeBlogShow: true,
|
||||||
|
homeBlogTitle: "Derniers Articles",
|
||||||
|
homeBlogSubtitle: "Toutes les actualités de l'entrepreneuriat africain.",
|
||||||
|
homeBlogCount: 3,
|
||||||
|
homeBlogSelection: "latest",
|
||||||
|
homeBlogIds: [],
|
||||||
|
homeBlogCategories: [],
|
||||||
|
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"],
|
||||||
|
resetPasswordSubject: "Réinitialisation de votre mot de passe - Afrohub",
|
||||||
|
resetPasswordTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
|
||||||
|
<p>Bonjour {name},</p>
|
||||||
|
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
|
||||||
|
</div>
|
||||||
|
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 {siteName}. Tous droits réservés.</p>
|
||||||
|
</div>`,
|
||||||
|
verifyEmailSubject: "Vérification de votre compte - Afrohub",
|
||||||
|
verifyEmailTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
|
||||||
|
<p>Bonjour {name},</p>
|
||||||
|
<p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
|
||||||
|
</div>
|
||||||
|
<p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 {siteName}. Tous droits réservés.</p>
|
||||||
|
</div>`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!settings) return defaultSettings;
|
if (!settings) return defaultSettings;
|
||||||
@@ -43,7 +75,7 @@ export async function updateSiteSettings(data: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath('/settings');
|
revalidatePath('/settings');
|
||||||
revalidatePath('/', 'layout'); // Revalidate all public pages because footer/header might use it
|
revalidatePath('/', 'layout');
|
||||||
|
|
||||||
return { success: true, settings };
|
return { success: true, settings };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
114
admin/src/app/actions/slides.ts
Normal file
114
admin/src/app/actions/slides.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function getSlides() {
|
||||||
|
try {
|
||||||
|
return await prisma.homeSlide.findMany({
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: { business: true }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get slides:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSlide(data: any) {
|
||||||
|
try {
|
||||||
|
await prisma.homeSlide.create({
|
||||||
|
data: {
|
||||||
|
type: data.type,
|
||||||
|
businessId: data.businessId || null,
|
||||||
|
title: data.title || null,
|
||||||
|
subtitle: data.subtitle || null,
|
||||||
|
imageUrl: data.imageUrl || null,
|
||||||
|
linkUrl: data.linkUrl || null,
|
||||||
|
order: data.order || 0,
|
||||||
|
isActive: data.isActive ?? true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
revalidatePath("/slides");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create slide:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSlide(id: string, data: any) {
|
||||||
|
try {
|
||||||
|
await prisma.homeSlide.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
type: data.type,
|
||||||
|
businessId: data.businessId || null,
|
||||||
|
title: data.title || null,
|
||||||
|
subtitle: data.subtitle || null,
|
||||||
|
imageUrl: data.imageUrl || null,
|
||||||
|
linkUrl: data.linkUrl || null,
|
||||||
|
order: data.order || 0,
|
||||||
|
isActive: data.isActive ?? true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
revalidatePath("/slides");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update slide:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSlide(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.homeSlide.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
revalidatePath("/slides");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete slide:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la suppression" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleSlideStatus(id: string, currentStatus: boolean) {
|
||||||
|
try {
|
||||||
|
await prisma.homeSlide.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: !currentStatus }
|
||||||
|
});
|
||||||
|
revalidatePath("/slides");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle slide status:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchBusinesses(query: string) {
|
||||||
|
try {
|
||||||
|
return await prisma.business.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ location: { contains: query, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
logoUrl: true,
|
||||||
|
coverUrl: true,
|
||||||
|
description: true,
|
||||||
|
location: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to search businesses:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
138
admin/src/app/actions/taxonomies.ts
Normal file
138
admin/src/app/actions/taxonomies.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function getAllTags() {
|
||||||
|
try {
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({ select: { tags: true } }),
|
||||||
|
prisma.interview.findMany({ select: { tags: true } }),
|
||||||
|
prisma.event.findMany({ select: { tags: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allTags = new Set<string>();
|
||||||
|
blogPosts.forEach(p => p.tags.forEach(t => allTags.add(t)));
|
||||||
|
interviews.forEach(i => i.tags.forEach(t => allTags.add(t)));
|
||||||
|
events.forEach(e => e.tags.forEach(t => allTags.add(t)));
|
||||||
|
|
||||||
|
return Array.from(allTags).sort();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tags:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTagGlobally(tagName: string) {
|
||||||
|
try {
|
||||||
|
// This is expensive as we need to update all records
|
||||||
|
// In a real app, a Tag model would be better
|
||||||
|
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({ where: { tags: { has: tagName } } }),
|
||||||
|
prisma.interview.findMany({ where: { tags: { has: tagName } } }),
|
||||||
|
prisma.event.findMany({ where: { tags: { has: tagName } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...blogPosts.map(p => prisma.blogPost.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { tags: { set: p.tags.filter(t => t !== tagName) } }
|
||||||
|
})),
|
||||||
|
...interviews.map(i => prisma.interview.update({
|
||||||
|
where: { id: i.id },
|
||||||
|
data: { tags: { set: i.tags.filter(t => t !== tagName) } }
|
||||||
|
})),
|
||||||
|
...events.map(e => prisma.event.update({
|
||||||
|
where: { id: e.id },
|
||||||
|
data: { tags: { set: e.tags.filter(t => t !== tagName) } }
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
revalidatePath('/taxonomies');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting tag globally:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la suppression du tag' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameTagGlobally(oldName: string, newName: string) {
|
||||||
|
try {
|
||||||
|
const cleanNewName = newName.trim().toLowerCase();
|
||||||
|
if (!cleanNewName) return { success: false, error: 'Nouveau nom invalide' };
|
||||||
|
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({ where: { tags: { has: oldName } } }),
|
||||||
|
prisma.interview.findMany({ where: { tags: { has: oldName } } }),
|
||||||
|
prisma.event.findMany({ where: { tags: { has: oldName } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...blogPosts.map(p => prisma.blogPost.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { tags: { set: p.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||||
|
})),
|
||||||
|
...interviews.map(i => prisma.interview.update({
|
||||||
|
where: { id: i.id },
|
||||||
|
data: { tags: { set: i.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||||
|
})),
|
||||||
|
...events.map(e => prisma.event.update({
|
||||||
|
where: { id: e.id },
|
||||||
|
data: { tags: { set: e.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
revalidatePath('/taxonomies');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error renaming tag globally:', error);
|
||||||
|
return { success: false, error: 'Erreur lors du renommage du tag' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTagUsage(tagName: string) {
|
||||||
|
try {
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({
|
||||||
|
where: { tags: { has: tagName } },
|
||||||
|
select: { id: true, title: true, status: true, author: true }
|
||||||
|
}),
|
||||||
|
prisma.interview.findMany({
|
||||||
|
where: { tags: { has: tagName } },
|
||||||
|
select: { id: true, title: true, status: true, guestName: true }
|
||||||
|
}),
|
||||||
|
prisma.event.findMany({
|
||||||
|
where: { tags: { has: tagName } },
|
||||||
|
select: { id: true, title: true, status: true, location: true }
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
blogPosts: blogPosts.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
status: p.status,
|
||||||
|
type: 'blog'
|
||||||
|
})),
|
||||||
|
interviews: interviews.map(i => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
status: i.status,
|
||||||
|
type: 'interview'
|
||||||
|
})),
|
||||||
|
events: events.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
title: e.title,
|
||||||
|
status: e.status,
|
||||||
|
type: 'event'
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tag usage:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la récupération de l\'usage' };
|
||||||
|
}
|
||||||
|
}
|
||||||
42
admin/src/app/actions/upload.ts
Normal file
42
admin/src/app/actions/upload.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export async function uploadImage(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
if (!file) {
|
||||||
|
return { success: false, error: 'Aucun fichier fourni' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
|
// Create a unique filename
|
||||||
|
const fileExtension = file.name.split('.').pop();
|
||||||
|
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
|
||||||
|
|
||||||
|
// Path for the ROOT public/uploads (where the main site will look)
|
||||||
|
const rootPublicDir = join(process.cwd(), '..', 'public', 'uploads');
|
||||||
|
// Path for the ADMIN public/uploads (where the admin app can see it for preview)
|
||||||
|
const adminPublicDir = join(process.cwd(), 'public', 'uploads');
|
||||||
|
|
||||||
|
// Ensure both directories exist
|
||||||
|
await mkdir(rootPublicDir, { recursive: true });
|
||||||
|
await mkdir(adminPublicDir, { recursive: true });
|
||||||
|
|
||||||
|
// Save to both
|
||||||
|
await writeFile(join(rootPublicDir, fileName), buffer);
|
||||||
|
await writeFile(join(adminPublicDir, fileName), buffer);
|
||||||
|
|
||||||
|
// Return the relative URL
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url: `/uploads/${fileName}`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during image upload:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de l\'upload de l\'image' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,3 +25,19 @@ export async function getClients() {
|
|||||||
return { success: false, error: "Erreur lors de la récupération des clients" };
|
return { success: false, error: "Erreur lors de la récupération des clients" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function verifyUserEmail(userId: string) {
|
||||||
|
try {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
emailVerified: true,
|
||||||
|
verificationToken: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to verify user email:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la vérification de l'email" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
5
admin/src/app/actualites/new/page.tsx
Normal file
5
admin/src/app/actualites/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import ActualitesForm from "@/components/ActualitesForm";
|
||||||
|
|
||||||
|
export default function NewActualitesPage() {
|
||||||
|
return <ActualitesForm />;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
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';
|
||||||
|
|
||||||
async function getData() {
|
async function getData() {
|
||||||
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
|
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
|
||||||
@@ -12,18 +13,32 @@ async function getData() {
|
|||||||
return { posts, interviews, events };
|
return { posts, interviews, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogCMSPage() {
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-500 border border-amber-500/20 flex items-center gap-1 w-fit"><Clock className="w-3 h-3" /> EN ATTENTE</span>;
|
||||||
|
case 'DRAFT': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-500/10 text-slate-400 border border-slate-500/20">BROUILLON</span>;
|
||||||
|
case 'PUBLISHED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">PUBLIÉ</span>;
|
||||||
|
case 'ARCHIVED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-red-500/10 text-red-400 border border-red-500/20 text-xs tracking-tighter">REJETÉ/ARCHIVÉ</span>;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ActualitesCMSPage() {
|
||||||
const { posts, interviews, events } = await getData();
|
const { 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>
|
||||||
@@ -38,18 +53,84 @@ export default async function BlogCMSPage() {
|
|||||||
</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">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left py-3 px-4">Titre</th>
|
<th className="text-left py-3 px-4">Titre</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
<th className="text-left py-3 px-4">Auteur</th>
|
<th className="text-left py-3 px-4">Auteur</th>
|
||||||
<th className="text-left py-3 px-4">Date</th>
|
<th className="text-left py-3 px-4">Date</th>
|
||||||
<th className="text-right py-3 px-4">Actions</th>
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
@@ -62,14 +143,15 @@ export default async function BlogCMSPage() {
|
|||||||
<div className="font-medium text-white">{post.title}</div>
|
<div className="font-medium text-white">{post.title}</div>
|
||||||
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
|
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-4 px-4">{getStatusBadge(post.status)}</td>
|
||||||
<td className="py-4 px-4 text-sm text-slate-300">{post.author}</td>
|
<td className="py-4 px-4 text-sm text-slate-300">{post.author}</td>
|
||||||
<td className="py-4 px-4 text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
|
<td className="py-4 px-4 text-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>
|
||||||
@@ -90,6 +172,7 @@ export default async function BlogCMSPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left py-3 px-4">Interviewé</th>
|
<th className="text-left py-3 px-4">Interviewé</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
<th className="text-left py-3 px-4">Entreprise / Rôle</th>
|
<th className="text-left py-3 px-4">Entreprise / Rôle</th>
|
||||||
<th className="text-left py-3 px-4">Type</th>
|
<th className="text-left py-3 px-4">Type</th>
|
||||||
<th className="text-right py-3 px-4">Actions</th>
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
@@ -102,6 +185,7 @@ export default async function BlogCMSPage() {
|
|||||||
<div className="font-medium text-white">{interview.guestName}</div>
|
<div className="font-medium text-white">{interview.guestName}</div>
|
||||||
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
|
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-4 px-4">{getStatusBadge(interview.status)}</td>
|
||||||
<td className="py-4 px-4">
|
<td className="py-4 px-4">
|
||||||
<div className="text-sm text-slate-300">{interview.companyName}</div>
|
<div className="text-sm text-slate-300">{interview.companyName}</div>
|
||||||
<div className="text-xs text-slate-500">{interview.role}</div>
|
<div className="text-xs text-slate-500">{interview.role}</div>
|
||||||
@@ -137,6 +221,7 @@ export default async function BlogCMSPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left py-3 px-4">Événement</th>
|
<th className="text-left py-3 px-4">Événement</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
<th className="text-left py-3 px-4">Date & Lieu</th>
|
<th className="text-left py-3 px-4">Date & Lieu</th>
|
||||||
<th className="text-right py-3 px-4">Actions</th>
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -150,6 +235,7 @@ export default async function BlogCMSPage() {
|
|||||||
{event.link ? 'Lien activé' : 'Pas de lien'}
|
{event.link ? 'Lien activé' : 'Pas de lien'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-4 px-4">{getStatusBadge(event.status)}</td>
|
||||||
<td className="py-4 px-4">
|
<td className="py-4 px-4">
|
||||||
<div className="flex items-center text-sm text-slate-300 gap-2">
|
<div className="flex items-center text-sm text-slate-300 gap-2">
|
||||||
<Calendar className="w-3 h-3 text-slate-500" />
|
<Calendar className="w-3 h-3 text-slate-500" />
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import BlogForm from "@/components/BlogForm";
|
|
||||||
|
|
||||||
export default function NewBlogPage() {
|
|
||||||
return <BlogForm />;
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { getClients } from "@/app/actions/user";
|
|
||||||
import { User, Mail, Phone, MapPin } from "lucide-react";
|
|
||||||
import ClientActions from "@/components/ClientActions";
|
|
||||||
|
|
||||||
export default async function ClientsPage() {
|
|
||||||
const result = await getClients();
|
|
||||||
const clients = result.success ? (result.data || []) : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Clients</h1>
|
|
||||||
<p className="text-slate-400">Liste des utilisateurs n'ayant pas encore de boutique active.</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800/50 px-4 py-2 rounded-lg border border-slate-700/50">
|
|
||||||
<span className="text-slate-400 text-sm">Total Clients: </span>
|
|
||||||
<span className="text-indigo-400 font-bold ml-1">{clients.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Utilisateur</th>
|
|
||||||
<th>Contact</th>
|
|
||||||
<th>Localisation</th>
|
|
||||||
<th>Statut</th>
|
|
||||||
<th className="text-right">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-800">
|
|
||||||
{clients.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="py-12 text-center text-slate-500">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<User className="w-8 h-8 opacity-20" />
|
|
||||||
<p>Aucun client trouvé.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
clients.map((client: any) => (
|
|
||||||
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
|
|
||||||
<td className="py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
|
|
||||||
{client.avatar ? (
|
|
||||||
<img src={client.avatar} alt={client.name} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
client.name.charAt(0)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-semibold">{client.name}</p>
|
|
||||||
<p className="text-slate-500 text-xs">ID: {client.id.split('-')[0]}...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
|
||||||
<Mail className="w-3 h-3 text-slate-500" />
|
|
||||||
{client.email}
|
|
||||||
</div>
|
|
||||||
{client.phone && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
|
||||||
<Phone className="w-3 h-3 text-slate-500" />
|
|
||||||
{client.phone}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
|
||||||
<MapPin className="w-3 h-3" />
|
|
||||||
{client.location || "Non spécifié"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{client.isSuspended ? (
|
|
||||||
<>
|
|
||||||
<span className="badge badge-error">Compte Suspendu</span>
|
|
||||||
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
|
|
||||||
{client.suspensionReason}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : client.businesses ? (
|
|
||||||
<span className="badge badge-pending">Shop Inactif</span>
|
|
||||||
) : (
|
|
||||||
<span className="badge bg-slate-800 text-slate-400">Actif</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useTransition } from 'react';
|
import React, { useState, useEffect, useTransition } from 'react';
|
||||||
import { getCountries, addCountry, deleteCountry, toggleCountryStatus } from '@/app/actions/countries';
|
import { getCountries, addCountry, deleteCountry, toggleCountryStatus, updateCountryDescription } from '@/app/actions/countries';
|
||||||
import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag } from 'lucide-react';
|
import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag, Edit2, Check, X } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
export default function CountriesPage() {
|
export default function CountriesPage() {
|
||||||
const [countries, setCountries] = useState<any[]>([]);
|
const [countries, setCountries] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editDesc, setEditDesc] = useState("");
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -27,9 +29,10 @@ export default function CountriesPage() {
|
|||||||
const name = formData.get('name') as string;
|
const name = formData.get('name') as string;
|
||||||
const code = formData.get('code') as string;
|
const code = formData.get('code') as string;
|
||||||
const flag = formData.get('flag') as string;
|
const flag = formData.get('flag') as string;
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await addCountry({ name, code, flag });
|
const result = await addCountry({ name, code, flag, description });
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Pays ajouté avec succès");
|
toast.success("Pays ajouté avec succès");
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
@@ -55,10 +58,22 @@ export default function CountriesPage() {
|
|||||||
const handleToggleStatus = async (id: string, current: boolean) => {
|
const handleToggleStatus = async (id: string, current: boolean) => {
|
||||||
const result = await toggleCountryStatus(id, !current);
|
const result = await toggleCountryStatus(id, !current);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
toast.success(!current ? "Pays activé" : "Pays désactivé");
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveDescription = async (id: string) => {
|
||||||
|
const result = await updateCountryDescription(id, editDesc);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Description mise à jour");
|
||||||
|
setEditingId(null);
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error("Erreur lors de la mise à jour");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-20">
|
<div className="flex items-center justify-center p-20">
|
||||||
@@ -85,26 +100,34 @@ export default function CountriesPage() {
|
|||||||
{isAdding && (
|
{isAdding && (
|
||||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl p-6 mb-8 animate-in fade-in slide-in-from-top-2">
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl p-6 mb-8 animate-in fade-in slide-in-from-top-2">
|
||||||
<h2 className="text-lg font-bold text-white mb-4">Nouveau Pays</h2>
|
<h2 className="text-lg font-bold text-white mb-4">Nouveau Pays</h2>
|
||||||
<form onSubmit={handleAdd} className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
<form onSubmit={handleAdd} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<label className="text-sm font-medium text-slate-400">Nom du pays</label>
|
<div className="space-y-2">
|
||||||
<input name="name" required placeholder="Ex: Sénégal" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
<label className="text-sm font-medium text-slate-400">Nom du pays</label>
|
||||||
|
<input name="name" required placeholder="Ex: Sénégal" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Code ISO (2 lettres)</label>
|
||||||
|
<input name="code" required maxLength={2} placeholder="Ex: SN" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 uppercase" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Emoji Drapeau / URL Icone</label>
|
||||||
|
<input name="flag" placeholder="Ex: 🇸🇳" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Code ISO (2 lettres)</label>
|
<label className="text-sm font-medium text-slate-400">Note / Description (interne)</label>
|
||||||
<input name="code" required maxLength={2} placeholder="Ex: SN" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 uppercase" />
|
<textarea name="description" placeholder="Ajouter un commentaire sur ce pays..." rows={2} className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Emoji Drapeau / URL Icone</label>
|
<div className="flex gap-2 justify-end">
|
||||||
<input name="flag" placeholder="Ex: 🇸🇳" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-700 text-white px-6 py-3 rounded-xl font-bold transition-all">
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button disabled={isPending} type="submit" className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white p-3 rounded-xl font-bold transition-all disabled:opacity-50">
|
|
||||||
{isPending ? 'Chargement...' : 'Enregistrer'}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-700 text-white p-3 rounded-xl font-bold transition-all">
|
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
|
<button disabled={isPending} type="submit" className="bg-emerald-600 hover:bg-emerald-700 text-white px-10 py-3 rounded-xl font-bold transition-all disabled:opacity-50">
|
||||||
|
{isPending ? 'Chargement...' : 'Enregistrer'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +139,7 @@ export default function CountriesPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4">Pays</th>
|
<th className="px-6 py-4">Pays</th>
|
||||||
<th className="px-6 py-4">Code</th>
|
<th className="px-6 py-4">Code</th>
|
||||||
|
<th className="px-6 py-4">Description / Notes</th>
|
||||||
<th className="px-6 py-4">Status</th>
|
<th className="px-6 py-4">Status</th>
|
||||||
<th className="px-6 py-4 text-right">Actions</th>
|
<th className="px-6 py-4 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -123,7 +147,7 @@ export default function CountriesPage() {
|
|||||||
<tbody className="divide-y divide-slate-800">
|
<tbody className="divide-y divide-slate-800">
|
||||||
{countries.length === 0 ? (
|
{countries.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-6 py-10 text-center text-slate-500">Aucun pays configuré.</td>
|
<td colSpan={5} className="px-6 py-10 text-center text-slate-500">Aucun pays configuré.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : countries.map((country) => (
|
) : countries.map((country) => (
|
||||||
<tr key={country.id} className="hover:bg-slate-800/30 transition-colors">
|
<tr key={country.id} className="hover:bg-slate-800/30 transition-colors">
|
||||||
@@ -134,15 +158,55 @@ export default function CountriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 font-mono text-indigo-400">{country.code}</td>
|
<td className="px-6 py-4 font-mono text-indigo-400">{country.code}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{editingId === country.id ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={editDesc}
|
||||||
|
onChange={(e) => setEditDesc(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-slate-700 rounded-lg px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500 w-full"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={() => handleSaveDescription(country.id)} className="text-emerald-500 hover:text-emerald-400">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="text-slate-500 hover:text-slate-400">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="group flex items-center gap-2 max-w-xs">
|
||||||
|
<span className="text-slate-400 text-sm truncate">
|
||||||
|
{country.description || <span className="italic text-slate-600">Pas de note</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(country.id);
|
||||||
|
setEditDesc(country.description || "");
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-white transition-opacity"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleStatus(country.id, country.isActive)}
|
onClick={() => handleToggleStatus(country.id, country.isActive)}
|
||||||
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold ${
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
|
||||||
country.isActive ? 'bg-emerald-500/10 text-emerald-500' : 'bg-slate-500/10 text-slate-500'
|
country.isActive ? 'bg-indigo-600' : 'bg-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{country.isActive ? 'Actif' : 'Inactif'}
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
country.isActive ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<span className={`ml-3 text-xs font-bold ${country.isActive ? 'text-emerald-500' : 'text-slate-500'}`}>
|
||||||
|
{country.isActive ? 'ACTIF' : 'INACTIF'}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<button onClick={() => handleDelete(country.id, country.name)} className="p-2 text-slate-500 hover:text-red-500 transition-colors">
|
<button onClick={() => handleDelete(country.id, country.name)} className="p-2 text-slate-500 hover:text-red-500 transition-colors">
|
||||||
|
|||||||
68
admin/src/app/dashboard/DashboardClient.tsx
Normal file
68
admin/src/app/dashboard/DashboardClient.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Store,
|
||||||
|
Clock,
|
||||||
|
MessageCircle,
|
||||||
|
Eye
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
usersCount: number;
|
||||||
|
businessCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
commentsCount: number;
|
||||||
|
totalViews: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardClient({ stats }: { stats: Stats }) {
|
||||||
|
const statCards = [
|
||||||
|
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
||||||
|
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
|
||||||
|
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
|
||||||
|
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
|
||||||
|
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
|
||||||
|
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
|
||||||
|
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<div key={card.label} className="card">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
|
||||||
|
<card.icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-white">{card.value}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-slate-400 font-medium">{card.label}</h3>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import {
|
import DashboardClient from './DashboardClient';
|
||||||
Users,
|
|
||||||
Store,
|
|
||||||
Clock,
|
|
||||||
MessageCircle,
|
|
||||||
Eye
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
async function getStats() {
|
async function getStats() {
|
||||||
const usersCount = await prisma.user.count();
|
const usersCount = await prisma.user.count();
|
||||||
@@ -32,51 +26,5 @@ async function getStats() {
|
|||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const stats = await getStats();
|
const stats = await getStats();
|
||||||
|
return <DashboardClient stats={stats} />;
|
||||||
const statCards = [
|
|
||||||
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
|
||||||
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
|
|
||||||
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
|
|
||||||
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
|
|
||||||
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
|
|
||||||
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
|
|
||||||
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-grid">
|
|
||||||
{statCards.map((card) => (
|
|
||||||
<div key={card.label} className="card">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
|
|
||||||
<card.icon className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold text-white">{card.value}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-slate-400 font-medium">{card.label}</h3>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
|
||||||
import FeaturedModal from '@/components/FeaturedModal';
|
|
||||||
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
|
||||||
import PlanSelector from '@/components/PlanSelector';
|
|
||||||
import { ShieldAlert, ShieldX, ShieldCheck } from 'lucide-react';
|
|
||||||
|
|
||||||
async function getBusinesses() {
|
|
||||||
return await prisma.business.findMany({
|
|
||||||
where: {
|
|
||||||
isActive: true
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
owner: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function EntrepreneursPage() {
|
|
||||||
const businesses = await getBusinesses();
|
|
||||||
const pendingCount = businesses.filter((b: any) =>
|
|
||||||
!b.verified &&
|
|
||||||
!b.isSuspended &&
|
|
||||||
['BOOSTER', 'EMPIRE'].includes(b.plan)
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Entrepreneurs</h1>
|
|
||||||
<p className="text-slate-400">Gérer les abonnements et certifier les comptes (Badge Bleu).</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pendingCount > 0 && (
|
|
||||||
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-3 rounded-2xl flex items-center gap-4 animate-pulse">
|
|
||||||
<div className="w-10 h-10 bg-blue-500 rounded-xl flex items-center justify-center text-white">
|
|
||||||
<ShieldCheck className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-blue-400 font-bold">{pendingCount} en attente</div>
|
|
||||||
<div className="text-xs text-blue-400/80 font-medium uppercase tracking-wider">Certifications Booster</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card overflow-hidden p-0">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Entreprise</th>
|
|
||||||
<th>Catégorie</th>
|
|
||||||
<th>Propriétaire</th>
|
|
||||||
<th>Statut</th>
|
|
||||||
<th className="text-center">Forfait</th>
|
|
||||||
<th className="text-center">Visibilité</th>
|
|
||||||
<th className="text-center">Mise en avant</th>
|
|
||||||
<th className="text-right">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{businesses.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="text-center py-10 text-slate-500">
|
|
||||||
Aucun entrepreneur trouvé.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
businesses.map((business: any) => (
|
|
||||||
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
|
|
||||||
<td>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
|
|
||||||
{business.logoUrl ? (
|
|
||||||
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-slate-500">LOGO</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-white">{business.name}</div>
|
|
||||||
<div className="text-xs text-slate-500">{business.location}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="text-sm text-slate-300">{business.category}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="text-sm text-white">{business.owner.name}</div>
|
|
||||||
<div className="text-xs text-slate-500">{business.owner.email}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{business.owner?.isSuspended ? (
|
|
||||||
<span className="badge badge-error flex items-center gap-1">
|
|
||||||
<ShieldX className="w-3 h-3" /> Compte Suspendu
|
|
||||||
</span>
|
|
||||||
) : business.isSuspended ? (
|
|
||||||
<span className="badge bg-orange-500/10 text-orange-500 border border-orange-500/20 flex items-center gap-1">
|
|
||||||
<ShieldAlert className="w-3 h-3" /> Shop Masqué
|
|
||||||
</span>
|
|
||||||
) : business.verified ? (
|
|
||||||
<span className="badge badge-verified">Vérifié</span>
|
|
||||||
) : (
|
|
||||||
<span className="badge badge-pending">En attente</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="text-center">
|
|
||||||
<PlanSelector businessId={business.id} currentPlan={business.plan} />
|
|
||||||
</td>
|
|
||||||
<td className="text-center font-medium text-slate-300">
|
|
||||||
{business.viewCount.toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="text-center">
|
|
||||||
<FeaturedModal business={business} />
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
|
||||||
<ToggleVerifyButton id={business.id} verified={business.verified} />
|
|
||||||
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
|
|
||||||
<EntrepreneurActions
|
|
||||||
businessId={business.id}
|
|
||||||
businessName={business.name}
|
|
||||||
userId={business.owner.id}
|
|
||||||
userName={business.owner.name}
|
|
||||||
isBusinessSuspended={!!business.isSuspended}
|
|
||||||
isUserSuspended={!!business.owner?.isSuspended}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -265,6 +265,13 @@ body {
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-editor {
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
word-break: normal;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* Styles for the Divider (HR) */
|
/* Styles for the Divider (HR) */
|
||||||
.quill-dark-wrapper .ql-editor hr {
|
.quill-dark-wrapper .ql-editor hr {
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
38
admin/src/app/not-found.tsx
Normal file
38
admin/src/app/not-found.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { LayoutDashboard, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 mb-6">
|
||||||
|
<span className="text-4xl font-bold text-indigo-400">404</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Page d'administration introuvable</h1>
|
||||||
|
<p className="text-slate-400">La ressource que vous recherchez n'existe pas ou a été déplacée.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-5 h-5" />
|
||||||
|
Retour au Dashboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-slate-900 text-slate-300 border border-slate-800 rounded-xl font-semibold hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
Page précédente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,19 +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');
|
||||||
|
|
||||||
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();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -29,9 +42,27 @@ export default function PlansPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="mb-8">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Tarifs & Forfaits</h1>
|
<div>
|
||||||
<p className="text-slate-400">Modifiez les noms, prix, limites d'offres et avantages affichés sur le site principal.</p>
|
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Forfaits & Services</h1>
|
||||||
|
<p className="text-slate-400">Modifiez les abonnements mensuels et les services ponctuels (One-Shot).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle Billing */}
|
||||||
|
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setBillingCycle('monthly')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all ${billingCycle === 'monthly' ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
Mensuel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBillingCycle('yearly')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all ${billingCycle === 'yearly' ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
Annuel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
@@ -55,16 +86,27 @@ export default function PlansPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-white mb-1 uppercase tracking-wider">{plan.name}</h3>
|
<h3 className="text-xl font-bold text-white mb-1 uppercase tracking-wider font-serif">{plan.name}</h3>
|
||||||
<p className="text-sm text-slate-400 line-clamp-1">{plan.description}</p>
|
<p className="text-sm text-slate-400 line-clamp-1">{plan.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price & Limit */}
|
{/* Price & Limit */}
|
||||||
<div className="p-6 bg-slate-950/30 flex-1">
|
<div className="p-6 bg-slate-950/30 flex-1">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="text-3xl font-bold text-white mb-1">{plan.priceXOF}</div>
|
<div className="text-3xl font-bold text-white mb-1">
|
||||||
<div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div>
|
{billingCycle === 'yearly'
|
||||||
</div>
|
? (plan.yearlyPriceXOF || plan.priceXOF)
|
||||||
|
: plan.priceXOF
|
||||||
|
}
|
||||||
|
<span className="text-xs text-slate-500 font-normal ml-2">
|
||||||
|
/ {billingCycle === 'yearly' ? 'an' : 'mois'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{billingCycle === 'yearly' && plan.yearlyPriceXOF && (
|
||||||
|
<div className="text-xs text-emerald-500 font-bold mb-2">PRIX ANNUEL ACTIF</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 mb-8">
|
<div className="space-y-3 mb-8">
|
||||||
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Avantages inclus :</div>
|
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Avantages inclus :</div>
|
||||||
@@ -96,8 +138,14 @@ export default function PlansPage() {
|
|||||||
isOpen={!!editingPlan}
|
isOpen={!!editingPlan}
|
||||||
onClose={() => setEditingPlan(null)}
|
onClose={() => setEditingPlan(null)}
|
||||||
plan={editingPlan}
|
plan={editingPlan}
|
||||||
|
currentCycle={billingCycle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* One Shot Plans Section */}
|
||||||
|
<div className="mt-16 pt-16 border-t border-slate-800">
|
||||||
|
<OneShotPricingSettings initialPlans={oneShotPlans} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,78 @@
|
|||||||
"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 } from 'lucide-react';
|
import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase } from 'lucide-react';
|
||||||
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
|
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { getBlogPosts } from '@/app/actions/actualites';
|
||||||
|
import RichTextEditor from '@/components/RichTextEditor';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
const BUSINESS_CATEGORIES = [
|
||||||
|
"Technologie & IT",
|
||||||
|
"Agriculture & Agrobusiness",
|
||||||
|
"Mode & Textile",
|
||||||
|
"Cosmétique & Beauté",
|
||||||
|
"Services aux entreprises",
|
||||||
|
"Restauration & Alimentation",
|
||||||
|
"Construction & BTP",
|
||||||
|
"Éducation & Formation",
|
||||||
|
"Santé & Bien-être",
|
||||||
|
"Artisanat & Déco",
|
||||||
|
"Tourisme & Loisirs",
|
||||||
|
"Finance & Assurance",
|
||||||
|
"Immobilier",
|
||||||
|
"Transport & Logistique",
|
||||||
|
"Média & Communication",
|
||||||
|
"Autre"
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOG_CATEGORIES = [
|
||||||
|
"Actualité",
|
||||||
|
"Conseils",
|
||||||
|
"Success Story",
|
||||||
|
"Événement",
|
||||||
|
"Formation"
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPage({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ tab?: string }>
|
||||||
|
}) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [settings, setSettings] = useState<any>(null);
|
const [settings, setSettings] = useState<any>(null);
|
||||||
|
const [allPosts, setAllPosts] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
||||||
|
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
|
||||||
|
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
|
||||||
|
const [resetPasswordTemplate, setResetPasswordTemplate] = useState('');
|
||||||
|
const [verifyEmailTemplate, setVerifyEmailTemplate] = useState('');
|
||||||
|
|
||||||
|
// Use a state for the tab if we want it to be fully client-side reactive without full reload
|
||||||
|
// But since the user wants it "like moderation center", I'll use URL search params
|
||||||
|
const [currentTab, setCurrentTab] = useState('general');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const data = await getSiteSettings();
|
try {
|
||||||
setSettings(data);
|
const [settingsData, postsData] = await Promise.all([
|
||||||
setLoading(false);
|
getSiteSettings(),
|
||||||
|
getBlogPosts()
|
||||||
|
]);
|
||||||
|
setSettings(settingsData);
|
||||||
|
setAllPosts(postsData);
|
||||||
|
setSelectedPostIds(settingsData?.homeBlogIds || []);
|
||||||
|
setSelectedHomeCategories(settingsData?.homeCategories || []);
|
||||||
|
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
|
||||||
|
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || '');
|
||||||
|
setVerifyEmailTemplate(settingsData?.verifyEmailTemplate || '');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load settings data:", error);
|
||||||
|
toast.error("Erreur lors du chargement des données");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -33,6 +91,18 @@ export default function SettingsPage() {
|
|||||||
instagramUrl: formData.get('instagramUrl'),
|
instagramUrl: formData.get('instagramUrl'),
|
||||||
linkedinUrl: formData.get('linkedinUrl'),
|
linkedinUrl: formData.get('linkedinUrl'),
|
||||||
footerText: formData.get('footerText'),
|
footerText: formData.get('footerText'),
|
||||||
|
homeBlogShow: formData.get('homeBlogShow') === 'on',
|
||||||
|
homeBlogTitle: formData.get('homeBlogTitle'),
|
||||||
|
homeBlogSubtitle: formData.get('homeBlogSubtitle'),
|
||||||
|
homeBlogCount: parseInt(formData.get('homeBlogCount') as string || '3'),
|
||||||
|
homeBlogSelection: formData.get('homeBlogSelection'),
|
||||||
|
homeBlogIds: selectedPostIds,
|
||||||
|
homeBlogCategories: selectedBlogCategories,
|
||||||
|
homeCategories: selectedHomeCategories,
|
||||||
|
resetPasswordSubject: formData.get('resetPasswordSubject'),
|
||||||
|
resetPasswordTemplate: resetPasswordTemplate,
|
||||||
|
verifyEmailSubject: formData.get('verifyEmailSubject'),
|
||||||
|
verifyEmailTemplate: verifyEmailTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -45,6 +115,24 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const togglePostId = (id: string) => {
|
||||||
|
setSelectedPostIds(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleHomeCategory = (cat: string) => {
|
||||||
|
setSelectedHomeCategories(prev =>
|
||||||
|
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBlogCategory = (cat: string) => {
|
||||||
|
setSelectedBlogCategories(prev =>
|
||||||
|
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-20">
|
<div className="flex items-center justify-center p-20">
|
||||||
@@ -58,145 +146,370 @@ export default function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Configuration du Site</h1>
|
<h1 className="text-2xl font-bold text-white">Configuration du Site</h1>
|
||||||
<p className="text-slate-400">Gérez les informations globales et les coordonnées de contact du site principal.</p>
|
<p className="text-slate-400">Gérez les informations globales, les sections de l'accueil et les modèles d'emails.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs Switcher */}
|
||||||
|
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('general')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'general' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
Général
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('emails')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'emails' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
Configuration Mail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Section Générale */}
|
{currentTab === 'general' && (
|
||||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
{/* Section Générale */}
|
||||||
<Globe className="w-5 h-5 text-indigo-400" />
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
<h2 className="font-semibold text-white">Informations Générales</h2>
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
</div>
|
<Globe className="w-5 h-5 text-indigo-400" />
|
||||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
<h2 className="font-semibold text-white">Informations Générales</h2>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<label className="text-sm font-medium text-slate-400">Nom du site</label>
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<input
|
<div className="space-y-2">
|
||||||
name="siteName"
|
<label className="text-sm font-medium text-slate-400">Nom du site</label>
|
||||||
defaultValue={settings?.siteName}
|
<input
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
name="siteName"
|
||||||
/>
|
defaultValue={settings?.siteName}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Slogan du site</label>
|
||||||
|
<input
|
||||||
|
name="siteSlogan"
|
||||||
|
defaultValue={settings?.siteSlogan}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Texte du pied de page (Footer)</label>
|
||||||
|
<input
|
||||||
|
name="footerText"
|
||||||
|
defaultValue={settings?.footerText}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Slogan du site</label>
|
|
||||||
<input
|
|
||||||
name="siteSlogan"
|
|
||||||
defaultValue={settings?.siteSlogan}
|
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2 space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Texte du pied de page (Footer)</label>
|
|
||||||
<input
|
|
||||||
name="footerText"
|
|
||||||
defaultValue={settings?.footerText}
|
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section Contact */}
|
{/* 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">
|
||||||
<Mail className="w-5 h-5 text-emerald-400" />
|
<Briefcase className="w-5 h-5 text-yellow-400" />
|
||||||
<h2 className="font-semibold text-white">Coordonnées de Contact</h2>
|
<h2 className="font-semibold text-white">Secteurs en Vedette (Home)</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="p-6">
|
||||||
<div className="space-y-2">
|
<p className="text-sm text-slate-400 mb-4">Sélectionnez les secteurs d'activité à afficher sur la page d'accueil.</p>
|
||||||
<label className="text-sm font-medium text-slate-400">Email de support</label>
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
<div className="relative">
|
{BUSINESS_CATEGORIES.map(cat => (
|
||||||
<Mail className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<button
|
||||||
<input
|
key={cat}
|
||||||
name="contactEmail"
|
type="button"
|
||||||
defaultValue={settings?.contactEmail}
|
onClick={() => toggleHomeCategory(cat)}
|
||||||
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={`text-left p-2 rounded-lg border text-xs transition-all ${selectedHomeCategories.includes(cat)
|
||||||
/>
|
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
|
||||||
|
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Téléphone de contact</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
|
||||||
<input
|
|
||||||
name="contactPhone"
|
|
||||||
defaultValue={settings?.contactPhone}
|
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2 space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Adresse physique</label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
|
||||||
<input
|
|
||||||
name="address"
|
|
||||||
defaultValue={settings?.address}
|
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section Réseaux Sociaux */}
|
{/* Section Page d'Accueil - Blog */}
|
||||||
<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">
|
||||||
<Share2 className="w-5 h-5 text-blue-400" />
|
<Newspaper className="w-5 h-5 text-purple-400" />
|
||||||
<h2 className="font-semibold text-white">Réseaux Sociaux</h2>
|
<h2 className="font-semibold text-white">Section Blog sur l'Accueil</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center gap-3 p-3 bg-indigo-600/10 border border-indigo-500/20 rounded-xl">
|
||||||
<label className="text-sm font-medium text-slate-400">Facebook URL</label>
|
<input
|
||||||
<div className="relative">
|
type="checkbox"
|
||||||
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
name="homeBlogShow"
|
||||||
<input
|
id="homeBlogShow"
|
||||||
name="facebookUrl"
|
defaultChecked={settings?.homeBlogShow}
|
||||||
defaultValue={settings?.facebookUrl}
|
className="w-5 h-5 rounded border-slate-700 bg-slate-950 text-indigo-600 focus:ring-indigo-500"
|
||||||
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"
|
<label htmlFor="homeBlogShow" className="text-sm font-medium text-slate-300 font-bold uppercase tracking-wider">Activer cette section</label>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Titre de la section</label>
|
||||||
|
<input
|
||||||
|
name="homeBlogTitle"
|
||||||
|
defaultValue={settings?.homeBlogTitle}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
|
||||||
|
<input
|
||||||
|
name="homeBlogSubtitle"
|
||||||
|
defaultValue={settings?.homeBlogSubtitle}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-700 pt-6">
|
||||||
|
<label className="text-sm font-medium text-slate-400 block mb-3">Mode de sélection des articles</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'latest' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
|
||||||
|
<input type="radio" name="homeBlogSelection" value="latest" defaultChecked={settings?.homeBlogSelection === 'latest'} className="sr-only" />
|
||||||
|
<span className="text-white font-bold text-sm">Les plus récents</span>
|
||||||
|
<span className="text-xs text-slate-500 mt-1">Affiche automatiquement les X derniers articles.</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'manual' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
|
||||||
|
<input type="radio" name="homeBlogSelection" value="manual" defaultChecked={settings?.homeBlogSelection === 'manual'} className="sr-only" />
|
||||||
|
<span className="text-white font-bold text-sm">Sélection manuelle</span>
|
||||||
|
<span className="text-xs text-slate-500 mt-1">Choisissez précisément les articles à mettre en avant.</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'category' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
|
||||||
|
<input type="radio" name="homeBlogSelection" value="category" defaultChecked={settings?.homeBlogSelection === 'category'} className="sr-only" />
|
||||||
|
<span className="text-white font-bold text-sm">Par catégorie</span>
|
||||||
|
<span className="text-xs text-slate-500 mt-1">Affiche les articles appartenant à certaines catégories.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Nombre maximum d'articles</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="homeBlogCount"
|
||||||
|
defaultValue={settings?.homeBlogCount}
|
||||||
|
min="1" max="12"
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Selection UI */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Articles sélectionnés (si mode manuel)</label>
|
||||||
|
<div className="grid grid-cols-1 gap-2 max-h-60 overflow-y-auto p-2 bg-slate-950 rounded-xl border border-slate-700">
|
||||||
|
{allPosts.map(post => (
|
||||||
|
<button
|
||||||
|
key={post.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => togglePostId(post.id)}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${selectedPostIds.includes(post.id)
|
||||||
|
? 'bg-indigo-600/20 border-indigo-500/50 text-white'
|
||||||
|
: 'text-slate-400 hover:bg-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{post.title}</span>
|
||||||
|
{selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Selection UI */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Catégories d'articles (si mode catégorie)</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{BLOG_CATEGORIES.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleBlogCategory(cat)}
|
||||||
|
className={`px-4 py-2 rounded-full border text-xs transition-all ${selectedBlogCategories.includes(cat)
|
||||||
|
? 'bg-purple-600 border-purple-500 text-white'
|
||||||
|
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
|
{/* Section Contact */}
|
||||||
<div className="relative">
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
<input
|
<Mail className="w-5 h-5 text-emerald-400" />
|
||||||
name="twitterUrl"
|
<h2 className="font-semibold text-white">Coordonnées de Contact</h2>
|
||||||
defaultValue={settings?.twitterUrl}
|
</div>
|
||||||
placeholder="https://twitter.com/..."
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
<div className="space-y-2">
|
||||||
/>
|
<label className="text-sm font-medium text-slate-400">Email de support</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="contactEmail"
|
||||||
|
defaultValue={settings?.contactEmail}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Téléphone de contact</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="contactPhone"
|
||||||
|
defaultValue={settings?.contactPhone}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Adresse physique</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="address"
|
||||||
|
defaultValue={settings?.address}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Instagram URL</label>
|
{/* Section Réseaux Sociaux */}
|
||||||
<div className="relative">
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
<input
|
<Share2 className="w-5 h-5 text-blue-400" />
|
||||||
name="instagramUrl"
|
<h2 className="font-semibold text-white">Réseaux Sociaux</h2>
|
||||||
defaultValue={settings?.instagramUrl}
|
|
||||||
placeholder="https://instagram.com/..."
|
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<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">LinkedIn URL</label>
|
<label className="text-sm font-medium text-slate-400">Facebook URL</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="linkedinUrl"
|
name="facebookUrl"
|
||||||
defaultValue={settings?.linkedinUrl}
|
defaultValue={settings?.facebookUrl}
|
||||||
placeholder="https://linkedin.com/in/..."
|
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 className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="twitterUrl"
|
||||||
|
defaultValue={settings?.twitterUrl}
|
||||||
|
placeholder="https://twitter.com/..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Instagram URL</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="instagramUrl"
|
||||||
|
defaultValue={settings?.instagramUrl}
|
||||||
|
placeholder="https://instagram.com/..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">LinkedIn URL</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="linkedinUrl"
|
||||||
|
defaultValue={settings?.linkedinUrl}
|
||||||
|
placeholder="https://linkedin.com/in/..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'emails' && (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
{/* Section Mail */}
|
||||||
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-orange-400" />
|
||||||
|
<h2 className="font-semibold text-white">Gestion des Emails</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Password Reset */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">Réinitialisation de mot de passe</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
|
||||||
|
<input
|
||||||
|
name="resetPasswordSubject"
|
||||||
|
defaultValue={settings?.resetPasswordSubject}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={resetPasswordTemplate}
|
||||||
|
onChange={setResetPasswordTemplate}
|
||||||
|
placeholder="Contenu de l'email..."
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
Variables : <code className="text-indigo-400">{"{name}"}</code>, <code className="text-indigo-400">{"{resetUrl}"}</code>, <code className="text-indigo-400">{"{siteName}"}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800"></div>
|
||||||
|
|
||||||
|
{/* Account Verification */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider">Vérification de compte</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
|
||||||
|
<input
|
||||||
|
name="verifyEmailSubject"
|
||||||
|
defaultValue={settings?.verifyEmailSubject}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={verifyEmailTemplate}
|
||||||
|
onChange={setVerifyEmailTemplate}
|
||||||
|
placeholder="Contenu de l'email..."
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
Variables : <code className="text-indigo-400">{"{name}"}</code>, <code className="text-indigo-400">{"{verifyUrl}"}</code>, <code className="text-indigo-400">{"{siteName}"}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
@@ -216,3 +529,4 @@ export default function SettingsPage() {
|
|||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
415
admin/src/app/taxonomies/TaxonomiesClient.tsx
Normal file
415
admin/src/app/taxonomies/TaxonomiesClient.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useTransition } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
Briefcase,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
Sparkles,
|
||||||
|
Cpu,
|
||||||
|
Sprout,
|
||||||
|
Shirt,
|
||||||
|
Utensils,
|
||||||
|
HardHat,
|
||||||
|
Stethoscope,
|
||||||
|
GraduationCap,
|
||||||
|
Palette,
|
||||||
|
Plane,
|
||||||
|
Truck,
|
||||||
|
Wallet,
|
||||||
|
Zap,
|
||||||
|
Leaf,
|
||||||
|
Camera,
|
||||||
|
Music,
|
||||||
|
ShoppingBag,
|
||||||
|
Heart,
|
||||||
|
Home,
|
||||||
|
Hash,
|
||||||
|
Tags,
|
||||||
|
Search,
|
||||||
|
ExternalLink,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
User as UserIcon,
|
||||||
|
MapPin,
|
||||||
|
BookOpen,
|
||||||
|
Save
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
|
||||||
|
import { getAllTags, deleteTagGlobally, renameTagGlobally, getTagUsage } from '@/app/actions/taxonomies';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
const ICON_LIST = [
|
||||||
|
{ name: 'Briefcase', icon: Briefcase },
|
||||||
|
{ name: 'Cpu', icon: Cpu },
|
||||||
|
{ name: 'Sprout', icon: Sprout },
|
||||||
|
{ name: 'Shirt', icon: Shirt },
|
||||||
|
{ name: 'Sparkles', icon: Sparkles },
|
||||||
|
{ name: 'Utensils', icon: Utensils },
|
||||||
|
{ name: 'HardHat', icon: HardHat },
|
||||||
|
{ name: 'Stethoscope', icon: Stethoscope },
|
||||||
|
{ name: 'GraduationCap', icon: GraduationCap },
|
||||||
|
{ name: 'Palette', icon: Palette },
|
||||||
|
{ name: 'Plane', icon: Plane },
|
||||||
|
{ name: 'Truck', icon: Truck },
|
||||||
|
{ name: 'Wallet', icon: Wallet },
|
||||||
|
{ name: 'Zap', icon: Zap },
|
||||||
|
{ name: 'Leaf', icon: Leaf },
|
||||||
|
{ name: 'Camera', icon: Camera },
|
||||||
|
{ name: 'Music', icon: Music },
|
||||||
|
{ name: 'ShoppingBag', icon: ShoppingBag },
|
||||||
|
{ name: 'Heart', icon: Heart },
|
||||||
|
{ name: 'Home', icon: Home },
|
||||||
|
{ name: 'Tags', icon: Tags },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Tab = 'categories' | 'tags';
|
||||||
|
|
||||||
|
export default function TaxonomiesClient() {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('categories');
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
|
const [suggestions, setSuggestions] = useState<any[]>([]);
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Category Form State
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
icon: 'Briefcase',
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tag Form State
|
||||||
|
const [newTagName, setNewTagName] = useState('');
|
||||||
|
const [isEditingUsageName, setIsEditingUsageName] = useState(false);
|
||||||
|
const [usagePreview, setUsagePreview] = useState<{ tag: string, data: any } | null>(null);
|
||||||
|
const [loadingUsage, setLoadingUsage] = useState(false);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [catsRes, tagsRes, sugRes] = await Promise.all([
|
||||||
|
getCategories(),
|
||||||
|
getAllTags(),
|
||||||
|
getSuggestedCategories()
|
||||||
|
]);
|
||||||
|
setCategories(catsRes);
|
||||||
|
setTags(tagsRes);
|
||||||
|
setSuggestions(sugRes);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Erreur de chargement");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCategorySubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = editingCategory
|
||||||
|
? await updateCategory(editingCategory.id, formData)
|
||||||
|
: await createCategory(formData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(editingCategory ? "Catégorie mise à jour" : "Catégorie créée");
|
||||||
|
setShowAddModal(false);
|
||||||
|
setEditingCategory(null);
|
||||||
|
setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagDelete = async (tag: string) => {
|
||||||
|
if (!confirm(`Supprimer le tag "${tag}" globalement ?`)) return;
|
||||||
|
const result = await deleteTagGlobally(tag);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Tag supprimé partout");
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowUsage = async (tag: string) => {
|
||||||
|
setLoadingUsage(true);
|
||||||
|
const result = await getTagUsage(tag);
|
||||||
|
if (result.success) {
|
||||||
|
setUsagePreview({ tag, data: result.data });
|
||||||
|
}
|
||||||
|
setLoadingUsage(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center text-slate-500">Chargement...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Taxonomies</h1>
|
||||||
|
<p className="text-slate-400">Gérez les catégories et les mots-clés du site.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex bg-slate-900 p-1 rounded-xl border border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('categories')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'categories' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
Catégories
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('tags')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'tags' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
Tags / Mots-clés
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'categories' ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Tags className="w-5 h-5 text-indigo-400" />
|
||||||
|
Liste des catégories ({categories.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCategory(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); setShowAddModal(true); }}
|
||||||
|
className="btn-verify flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Nouvelle catégorie
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const IconComp = ICON_LIST.find(i => i.name === cat.icon)?.icon || Briefcase;
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="card p-4 flex items-center justify-between group">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 bg-slate-800 rounded-lg text-indigo-400">
|
||||||
|
<IconComp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-bold">{cat.name}</h3>
|
||||||
|
<p className="text-xs text-slate-500">/{cat.slug}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCategory(cat); setFormData({ name: cat.name, slug: cat.slug, icon: cat.icon, isActive: cat.isActive }); setShowAddModal(true); }}
|
||||||
|
className="p-2 text-slate-400 hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => { if (confirm("Supprimer ?")) await deleteCategory(cat.id); loadData(); }}
|
||||||
|
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Hash className="w-5 h-5 text-indigo-400" />
|
||||||
|
Tags utilisés ({tags.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
onClick={() => handleShowUsage(tag)}
|
||||||
|
className="group flex items-center justify-between bg-slate-950 border border-slate-800 rounded-xl p-3 hover:border-indigo-500/50 transition-all hover:bg-indigo-500/5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold text-slate-300 truncate group-hover:text-white">{tag}</span>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
||||||
|
<Trash2
|
||||||
|
className="w-3.5 h-3.5 text-slate-500 hover:text-red-400"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleTagDelete(tag); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Ajout/Modif Catégorie */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-3xl p-8 max-w-md w-full">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-6">{editingCategory ? 'Modifier la catégorie' : 'Nouvelle catégorie'}</h3>
|
||||||
|
<form onSubmit={handleCategorySubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-slate-400 mb-2 uppercase tracking-wider">Nom</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-white focus:border-indigo-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto p-1">
|
||||||
|
{ICON_LIST.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, icon: item.name })}
|
||||||
|
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${formData.icon === item.name ? 'bg-indigo-600/20 border-indigo-500 text-indigo-400' : 'bg-slate-950 border-slate-800 text-slate-500 hover:border-slate-700'}`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-8">
|
||||||
|
<button type="button" onClick={() => setShowAddModal(false)} className="flex-1 px-4 py-3 bg-slate-800 text-white rounded-xl font-bold hover:bg-slate-700 transition-all">Annuler</button>
|
||||||
|
<button type="submit" disabled={isPending} className="flex-1 px-4 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all flex items-center justify-center gap-2">
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||||
|
{editingCategory ? 'Mettre à jour' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage Modal */}
|
||||||
|
{usagePreview && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-3xl w-full max-w-4xl overflow-hidden shadow-2xl">
|
||||||
|
<div className="p-6 border-b border-slate-700 bg-slate-800/30 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Hash className="w-6 h-6 text-indigo-500" />
|
||||||
|
{isEditingUsageName ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newTagName}
|
||||||
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-indigo-500/50 rounded-xl px-4 py-2 text-white font-bold"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const res = await renameTagGlobally(usagePreview.tag, newTagName);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("Renommé");
|
||||||
|
setUsagePreview({ ...usagePreview, tag: newTagName });
|
||||||
|
setIsEditingUsageName(false);
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 bg-emerald-600 text-white rounded-lg"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-xl font-bold text-white uppercase tracking-tight">Tag : {usagePreview.tag}</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{!isEditingUsageName && (
|
||||||
|
<button onClick={() => { setNewTagName(usagePreview.tag); setIsEditingUsageName(true); }} className="p-2 text-slate-400 hover:text-indigo-400"><Edit2 className="w-4 h-4" /></button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setUsagePreview(null)} className="p-2 text-slate-400 hover:text-white"><X className="w-6 h-6" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 grid grid-cols-1 md:grid-cols-3 gap-6 max-h-[60vh] overflow-y-auto custom-scrollbar">
|
||||||
|
{/* Blog Posts */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<div className="p-1.5 bg-indigo-500/10 rounded-lg">
|
||||||
|
<BookOpen className="w-3.5 h-3.5 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Articles de Blog ({usagePreview.data.blogPosts.length})</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usagePreview.data.blogPosts.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-slate-600 italic px-2">Aucun article</p>
|
||||||
|
) : usagePreview.data.blogPosts.map((post: any) => (
|
||||||
|
<div key={post.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-indigo-500/30 transition-all">
|
||||||
|
<span className="text-sm font-medium text-slate-200 truncate pr-2">{post.title}</span>
|
||||||
|
<Link href={`/blog/edit/${post.id}`} className="p-1.5 bg-slate-800 hover:bg-indigo-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interviews */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<div className="p-1.5 bg-emerald-500/10 rounded-lg">
|
||||||
|
<UserIcon className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Interviews ({usagePreview.data.interviews.length})</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usagePreview.data.interviews.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-slate-600 italic px-2">Aucune interview</p>
|
||||||
|
) : usagePreview.data.interviews.map((item: any) => (
|
||||||
|
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-emerald-500/30 transition-all">
|
||||||
|
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
|
||||||
|
<Link href={`/interview/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-emerald-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<div className="p-1.5 bg-amber-500/10 rounded-lg">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Événements ({usagePreview.data.events.length})</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usagePreview.data.events.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-slate-600 italic px-2">Aucun événement</p>
|
||||||
|
) : usagePreview.data.events.map((item: any) => (
|
||||||
|
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-amber-500/30 transition-all">
|
||||||
|
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
|
||||||
|
<Link href={`/event/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-amber-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
admin/src/app/taxonomies/page.tsx
Normal file
5
admin/src/app/taxonomies/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import TaxonomiesClient from "./TaxonomiesClient";
|
||||||
|
|
||||||
|
export default function TaxonomiesPage() {
|
||||||
|
return <TaxonomiesClient />;
|
||||||
|
}
|
||||||
300
admin/src/app/users/page.tsx
Normal file
300
admin/src/app/users/page.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getClients } from "@/app/actions/user";
|
||||||
|
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
||||||
|
import FeaturedModal from '@/components/FeaturedModal';
|
||||||
|
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
||||||
|
import PlanSelector from '@/components/PlanSelector';
|
||||||
|
import ClientActions from "@/components/ClientActions";
|
||||||
|
import ManualVerifyButton from "@/components/ManualVerifyButton";
|
||||||
|
import { ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
async function getBusinesses() {
|
||||||
|
return await prisma.business.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UsersPage({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ tab?: string }>
|
||||||
|
}) {
|
||||||
|
const { tab = 'entrepreneurs' } = await searchParams;
|
||||||
|
|
||||||
|
// Data for Entrepreneurs
|
||||||
|
const businesses = await getBusinesses();
|
||||||
|
const pendingCount = businesses.filter((b: any) =>
|
||||||
|
!b.verified &&
|
||||||
|
!b.isSuspended &&
|
||||||
|
['BOOSTER', 'EMPIRE'].includes(b.plan)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Data for Clients/Visitors
|
||||||
|
const clientsResult = await getClients();
|
||||||
|
const clients = (clientsResult.success ? (clientsResult.data || []) : []) as any[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Utilisateurs</h1>
|
||||||
|
<p className="text-slate-400">Gérer les entrepreneurs et les visiteurs de la plateforme.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||||
|
<Link
|
||||||
|
href="/users?tab=entrepreneurs"
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'entrepreneurs' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Briefcase className="w-4 h-4" />
|
||||||
|
Entrepreneurs ({businesses.length})
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/users?tab=visiteurs"
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'visiteurs' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
Visiteurs ({clients.length})
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'entrepreneurs' ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-3 rounded-2xl flex items-center gap-4 animate-pulse max-w-fit">
|
||||||
|
<div className="w-10 h-10 bg-blue-500 rounded-xl flex items-center justify-center text-white">
|
||||||
|
<ShieldCheck className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-blue-400 font-bold">{pendingCount} en attente</div>
|
||||||
|
<div className="text-xs text-blue-400/80 font-medium uppercase tracking-wider">Certifications Booster</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card overflow-hidden p-0">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entreprise</th>
|
||||||
|
<th>Catégorie</th>
|
||||||
|
<th>Propriétaire</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th className="text-center">Forfait</th>
|
||||||
|
<th className="text-center">Mise en avant</th>
|
||||||
|
<th className="text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{businesses.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="text-center py-10 text-slate-500">
|
||||||
|
Aucun entrepreneur trouvé.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
businesses.map((business: any) => (
|
||||||
|
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
|
||||||
|
{business.logoUrl ? (
|
||||||
|
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500">LOGO</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-white">{business.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{business.location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-sm text-slate-300">{business.category}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="text-sm text-white">{business.owner.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{business.owner.email}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{business.owner.emailVerified ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
|
||||||
|
<MailCheck className="w-3.5 h-3.5" /> Vérifié
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
|
||||||
|
<MailX className="w-3.5 h-3.5" /> Non vérifié
|
||||||
|
</span>
|
||||||
|
<ManualVerifyButton userId={business.owner.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{business.owner?.isSuspended ? (
|
||||||
|
<span className="badge badge-error flex items-center gap-1">
|
||||||
|
<ShieldX className="w-3 h-3" /> Compte Suspendu
|
||||||
|
</span>
|
||||||
|
) : business.isSuspended ? (
|
||||||
|
<span className="badge bg-orange-500/10 text-orange-500 border border-orange-500/20 flex items-center gap-1">
|
||||||
|
<ShieldAlert className="w-3 h-3" /> Shop Masqué
|
||||||
|
</span>
|
||||||
|
) : business.verified ? (
|
||||||
|
<span className="badge badge-verified">Vérifié</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-pending">En attente</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<PlanSelector businessId={business.id} currentPlan={business.plan} />
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<FeaturedModal business={business} />
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<ToggleVerifyButton id={business.id} verified={business.verified} />
|
||||||
|
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
|
||||||
|
<EntrepreneurActions
|
||||||
|
businessId={business.id}
|
||||||
|
businessName={business.name}
|
||||||
|
userId={business.owner.id}
|
||||||
|
userName={business.owner.name}
|
||||||
|
isBusinessSuspended={!!business.isSuspended}
|
||||||
|
isUserSuspended={!!business.owner?.isSuspended}
|
||||||
|
metaTitle={business.metaTitle}
|
||||||
|
metaDescription={business.metaDescription}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Utilisateur</th>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>Vérification</th>
|
||||||
|
<th>Localisation</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th className="text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{clients.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Users className="w-8 h-8 opacity-20" />
|
||||||
|
<p>Aucun visiteur trouvé.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
clients.map((client: any) => (
|
||||||
|
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
|
||||||
|
{client.avatar ? (
|
||||||
|
<img src={client.avatar} alt={client.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
client.name.charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-semibold">{client.name}</p>
|
||||||
|
<p className="text-slate-500 text-xs">ID: {client.id.split('-')[0]}...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<Mail className="w-3 h-3 text-slate-500" />
|
||||||
|
{client.email}
|
||||||
|
</div>
|
||||||
|
{client.phone && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<Phone className="w-3 h-3 text-slate-500" />
|
||||||
|
{client.phone}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{client.emailVerified ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
|
||||||
|
<MailCheck className="w-3.5 h-3.5" /> Vérifié
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
|
||||||
|
<MailX className="w-3.5 h-3.5" /> Non vérifié
|
||||||
|
</span>
|
||||||
|
<ManualVerifyButton userId={client.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{client.location || "Non spécifié"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{client.isSuspended ? (
|
||||||
|
<>
|
||||||
|
<span className="badge badge-error">Compte Suspendu</span>
|
||||||
|
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
|
||||||
|
{client.suspensionReason}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : client.businesses ? (
|
||||||
|
<span className="badge badge-pending">Shop Inactif</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-slate-800 text-slate-400">Actif</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition, useState } from 'react';
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
|
import { createBlogPost, updateBlogPost } from '@/app/actions/actualites';
|
||||||
import { Loader2, ArrowLeft, Save } from 'lucide-react';
|
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive } 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';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
import { ContentStatus } from '@prisma/client';
|
||||||
|
import { getAllTags } from '@/app/actions/taxonomies';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialData?: {
|
initialData?: {
|
||||||
@@ -20,6 +24,8 @@ interface Props {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string | null;
|
metaTitle?: string | null;
|
||||||
metaDescription?: string | null;
|
metaDescription?: string | null;
|
||||||
|
publishedAt?: Date | string | null;
|
||||||
|
status?: ContentStatus;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,30 +33,53 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [content, setContent] = useState(initialData?.content || '');
|
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>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const publishedAtStr = formData.get('publishedAt') as string;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
title: formData.get('title') as string,
|
title: formData.get('title') as string,
|
||||||
slug: formData.get('slug') as string,
|
slug: formData.get('slug') as string,
|
||||||
excerpt: formData.get('excerpt') as string,
|
excerpt: formData.get('excerpt') as string,
|
||||||
content: content,
|
content: content,
|
||||||
author: formData.get('author') as string,
|
author: formData.get('author') as string,
|
||||||
imageUrl: formData.get('imageUrl') as string,
|
imageUrl: imageUrl, // Use state value
|
||||||
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
|
tags: tags, // Use state value
|
||||||
metaTitle: formData.get('metaTitle') as string,
|
metaTitle: formData.get('metaTitle') as string,
|
||||||
metaDescription: formData.get('metaDescription') 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 () => {
|
startTransition(async () => {
|
||||||
const result = initialData
|
const result = initialData
|
||||||
? await updateBlogPost(initialData.id, data)
|
? await updateBlogPost(initialData.id, data)
|
||||||
: await createBlogPost(data);
|
: await createBlogPost(data);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(initialData ? "Article mis à jour" : "Article publié avec succès");
|
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
|
||||||
router.push('/blog');
|
router.push('/actualites');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Une erreur est survenue");
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
@@ -62,7 +91,7 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
<div className="max-w-4xl mx-auto pb-20">
|
<div className="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">
|
||||||
@@ -73,7 +102,21 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
<div className="card space-y-6">
|
<div className="card space-y-6">
|
||||||
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={initialData?.status || 'PUBLISHED'}
|
||||||
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">Brouillon</option>
|
||||||
|
<option value="PUBLISHED">Publié</option>
|
||||||
|
<option value="ARCHIVED">Archivé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -98,15 +141,30 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<label className="text-sm font-medium text-slate-400">URL de l'image</label>
|
<div className="space-y-2">
|
||||||
<input
|
<ImageUploader
|
||||||
name="imageUrl"
|
label="Image de couverture"
|
||||||
defaultValue={initialData?.imageUrl}
|
value={imageUrl}
|
||||||
required
|
onChange={setImageUrl}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
name="imageUrl"
|
||||||
placeholder="https://images.unsplash.com/..."
|
/>
|
||||||
/>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">
|
||||||
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="publishedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={publishedAtValue}
|
||||||
|
onChange={(e) => setPublishedAtValue(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -147,12 +205,12 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
|
<TagInput
|
||||||
<input
|
value={tags}
|
||||||
name="tags"
|
onChange={setTags}
|
||||||
defaultValue={initialData?.tags?.join(', ') || ''}
|
suggestions={allExistingTags}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
label="Mots-clés (tags)"
|
||||||
placeholder="tech, afrique, innovation"
|
placeholder="tech, innovation, afrique..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +248,7 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Save className="w-6 h-6" />
|
<Save className="w-6 h-6" />
|
||||||
)}
|
)}
|
||||||
{initialData ? 'Enregistrer les modifications' : 'Publier l\'article'}
|
{initialData ? 'Enregistrer les modifications' : 'Créer l\'article'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
admin/src/components/BusinessSeoModal.tsx
Normal file
120
admin/src/components/BusinessSeoModal.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useTransition } from 'react';
|
||||||
|
import { X, Save, Loader2, Globe } from 'lucide-react';
|
||||||
|
import { updateBusinessSeo } from '@/app/actions/business';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface BusinessSeoModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
business: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
metaTitle?: string | null;
|
||||||
|
metaDescription?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BusinessSeoModal({ isOpen, onClose, business }: BusinessSeoModalProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
|
||||||
|
const [metaDescription, setMetaDescription] = useState(business.metaDescription || '');
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateBusinessSeo(business.id, {
|
||||||
|
metaTitle,
|
||||||
|
metaDescription
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Configuration SEO mise à jour");
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la mise à jour");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
|
||||||
|
<div className="p-6 border-b border-slate-800 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-500">
|
||||||
|
<Globe className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-white">SEO & Partage</h3>
|
||||||
|
<p className="text-xs text-slate-400">Personnaliser l'aperçu WhatsApp pour {business.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Titre (WhatsApp/Google)</label>
|
||||||
|
<input
|
||||||
|
value={metaTitle}
|
||||||
|
onChange={(e) => setMetaTitle(e.target.value)}
|
||||||
|
placeholder={`Ex: ${business.name} - Expert en ...`}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-all"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500 italic">Recommandé : Moins de 60 caractères.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Description</label>
|
||||||
|
<textarea
|
||||||
|
value={metaDescription}
|
||||||
|
onChange={(e) => setMetaDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Décrivez l'entreprise en quelques mots pour attirer les clics..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-all resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500 italic">Recommandé : Entre 140 et 160 caractères.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-xl p-4">
|
||||||
|
<h4 className="text-[10px] font-bold text-indigo-400 uppercase tracking-widest mb-2">Aperçu du partage</h4>
|
||||||
|
<div className="bg-slate-950 rounded-lg p-3 border border-slate-800">
|
||||||
|
<div className="text-sm font-bold text-indigo-400 line-clamp-1">{metaTitle || business.name}</div>
|
||||||
|
<div className="text-xs text-slate-500 line-clamp-2 mt-1">{metaDescription || "Découvrez cette entreprise sur Afroprenariat..."}</div>
|
||||||
|
<div className="text-[10px] text-slate-600 mt-2">afroprenariat.com</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-3 rounded-xl border border-slate-800 text-slate-400 font-bold hover:bg-slate-800 transition-all"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex-[2] py-3 rounded-xl bg-indigo-600 text-white font-bold hover:bg-indigo-500 transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ShieldAlert, ShieldCheck, ShoppingBag, User } from 'lucide-react';
|
import { ShieldAlert, ShieldCheck, ShoppingBag, User, Globe } from 'lucide-react';
|
||||||
import { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } from '@/app/actions/suspension';
|
import { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } 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 BusinessSeoModal from './BusinessSeoModal';
|
||||||
|
|
||||||
interface EntrepreneurActionsProps {
|
interface EntrepreneurActionsProps {
|
||||||
businessId: string;
|
businessId: string;
|
||||||
@@ -13,6 +14,8 @@ interface EntrepreneurActionsProps {
|
|||||||
userName: string;
|
userName: string;
|
||||||
isBusinessSuspended: boolean;
|
isBusinessSuspended: boolean;
|
||||||
isUserSuspended: boolean;
|
isUserSuspended: boolean;
|
||||||
|
metaTitle?: string | null;
|
||||||
|
metaDescription?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EntrepreneurActions({
|
export default function EntrepreneurActions({
|
||||||
@@ -21,9 +24,12 @@ export default function EntrepreneurActions({
|
|||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
isBusinessSuspended,
|
isBusinessSuspended,
|
||||||
isUserSuspended
|
isUserSuspended,
|
||||||
|
metaTitle,
|
||||||
|
metaDescription
|
||||||
}: EntrepreneurActionsProps) {
|
}: EntrepreneurActionsProps) {
|
||||||
const [modalType, setModalType] = useState<'USER' | 'BUSINESS' | null>(null);
|
const [modalType, setModalType] = useState<'USER' | 'BUSINESS' | null>(null);
|
||||||
|
const [seoModalOpen, setSeoModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleConfirm = async (reason: string) => {
|
const handleConfirm = async (reason: string) => {
|
||||||
if (modalType === 'USER') {
|
if (modalType === 'USER') {
|
||||||
@@ -41,6 +47,16 @@ export default function EntrepreneurActions({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
|
{/* SEO Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSeoModalOpen(true)}
|
||||||
|
className="btn-action bg-indigo-500/10 text-indigo-500 hover:bg-indigo-500 hover:text-white"
|
||||||
|
title="Modifier le SEO / Partage WhatsApp"
|
||||||
|
>
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] uppercase font-bold">SEO</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* User Suspension */}
|
{/* User Suspension */}
|
||||||
{isUserSuspended ? (
|
{isUserSuspended ? (
|
||||||
<button
|
<button
|
||||||
@@ -93,6 +109,17 @@ export default function EntrepreneurActions({
|
|||||||
? `Voulez-vous suspendre TOUT le compte de ${userName} ? Sa boutique sera également masquée.`
|
? `Voulez-vous suspendre TOUT le compte de ${userName} ? Sa boutique sera également masquée.`
|
||||||
: `Voulez-vous masquer la boutique "${businessName}" ? L'entrepreneur gardera l'accès à son compte.`}
|
: `Voulez-vous masquer la boutique "${businessName}" ? L'entrepreneur gardera l'accès à son compte.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BusinessSeoModal
|
||||||
|
isOpen={seoModalOpen}
|
||||||
|
onClose={() => setSeoModalOpen(false)}
|
||||||
|
business={{
|
||||||
|
id: businessId,
|
||||||
|
name: businessName,
|
||||||
|
metaTitle,
|
||||||
|
metaDescription
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition, useState } 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 } from 'lucide-react';
|
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } 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';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
import { ContentStatus } from '@prisma/client';
|
||||||
|
import { getAllTags } from '@/app/actions/taxonomies';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
@@ -16,23 +20,46 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [description, setDescription] = useState(initialData?.description || '');
|
const [description, setDescription] = useState(initialData?.description || '');
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
||||||
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
|
initialData?.publishedAt
|
||||||
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
||||||
|
: new Date().toISOString().slice(0, 16)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllTags().then(setAllExistingTags);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const publishedAtStr = formData.get('publishedAt') as string;
|
||||||
|
|
||||||
const data: any = {
|
const data: any = {
|
||||||
title: formData.get('title') as string,
|
title: formData.get('title') as string,
|
||||||
slug: formData.get('slug') as string,
|
slug: formData.get('slug') as string,
|
||||||
location: formData.get('location') as string,
|
location: formData.get('location') as string,
|
||||||
date: new Date(formData.get('date') as string),
|
date: new Date(formData.get('date') as string),
|
||||||
thumbnailUrl: formData.get('thumbnailUrl') as string,
|
thumbnailUrl: thumbnailUrl, // Use state value
|
||||||
link: formData.get('link') as string || null,
|
link: formData.get('link') as string || null,
|
||||||
description: description || '',
|
description: description || '',
|
||||||
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
|
tags: tags, // Use state
|
||||||
metaTitle: formData.get('metaTitle') as string,
|
metaTitle: formData.get('metaTitle') as string,
|
||||||
metaDescription: formData.get('metaDescription') as string,
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
|
status: formData.get('status') as ContentStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!data.thumbnailUrl) {
|
||||||
|
toast.error("Une image est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = initialData
|
const result = initialData
|
||||||
? await updateEvent(initialData.id, data)
|
? await updateEvent(initialData.id, data)
|
||||||
@@ -40,7 +67,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");
|
||||||
@@ -52,7 +79,7 @@ export default function EventForm({ 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">
|
||||||
@@ -63,7 +90,21 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
<div className="card space-y-6">
|
<div className="card space-y-6">
|
||||||
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={initialData?.status || 'PUBLISHED'}
|
||||||
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">Brouillon</option>
|
||||||
|
<option value="PUBLISHED">Publié</option>
|
||||||
|
<option value="ARCHIVED">Archivé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -77,7 +118,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Date de l'événement</label>
|
<label className="text-sm font-medium text-slate-400">Date de l'événement (Début)</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Calendar className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
<Calendar className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
@@ -105,6 +146,32 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">
|
||||||
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Send className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="publishedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={publishedAtValue}
|
||||||
|
onChange={(e) => setPublishedAtValue(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ImageUploader
|
||||||
|
label="Image (Affiche/Miniature)"
|
||||||
|
value={thumbnailUrl}
|
||||||
|
onChange={setThumbnailUrl}
|
||||||
|
name="thumbnailUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Lien d'inscription / info (Optionnel)</label>
|
<label className="text-sm font-medium text-slate-400">Lien d'inscription / info (Optionnel)</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -119,17 +186,6 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">URL de l'image (Affiche/Miniature)</label>
|
|
||||||
<input
|
|
||||||
name="thumbnailUrl"
|
|
||||||
defaultValue={initialData?.thumbnailUrl}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Description de l'événement</label>
|
<label className="text-sm font-medium text-slate-400">Description de l'événement</label>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
@@ -156,12 +212,12 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
|
<TagInput
|
||||||
<input
|
value={tags}
|
||||||
name="tags"
|
onChange={setTags}
|
||||||
defaultValue={initialData?.tags?.join(', ') || ''}
|
suggestions={allExistingTags}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
label="Mots-clés (tags)"
|
||||||
placeholder="evenement, networking, afrique"
|
placeholder="evenement, networking, afrique..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function FeaturedModal({ business }: Props) {
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await setFeaturedBusiness(business.id, data);
|
const result = await setFeaturedBusiness(business.id, data);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Entrepreneur du mois mis à jour !");
|
toast.success("Afroshine mis à jour !");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Une erreur est survenue");
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
@@ -41,7 +41,7 @@ export default function FeaturedModal({ business }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = async () => {
|
const handleRemove = async () => {
|
||||||
if (confirm("Retirer cet entrepreneur du titre 'Entrepreneur du mois' ?")) {
|
if (confirm("Retirer cet entrepreneur du titre 'Afroshine' ?")) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await removeFeaturedBusiness(business.id);
|
const result = await removeFeaturedBusiness(business.id);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -79,7 +79,7 @@ export default function FeaturedModal({ business }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">💰 Entrepreneur du Mois</h2>
|
<h2 className="text-2xl font-bold text-white mb-2">💰 Afroshine</h2>
|
||||||
<p className="text-slate-400">Configurez les détails pour {business.name}.</p>
|
<p className="text-slate-400">Configurez les détails pour {business.name}.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export default function FeaturedModal({ business }: Props) {
|
|||||||
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
|
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" />}
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||||
Définir comme Entrepreneur du Mois
|
Définir comme Afroshine
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{business.isFeatured && (
|
{business.isFeatured && (
|
||||||
|
|||||||
174
admin/src/components/ImageUploader.tsx
Normal file
174
admin/src/components/ImageUploader.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { Upload, Link as LinkIcon, X, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { uploadImage } from '@/app/actions/upload';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageUploader({ value, onChange, label, placeholder, name }: Props) {
|
||||||
|
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error("Le fichier doit être une image");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error("L'image ne doit pas dépasser 5 Mo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadImage(formData);
|
||||||
|
if (result.success && result.url) {
|
||||||
|
onChange(result.url);
|
||||||
|
toast.success("Image uploadée avec succès");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de l'upload");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Erreur de connexion lors de l'upload");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearImage = () => {
|
||||||
|
onChange('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-slate-400">{label || "Image"}</label>
|
||||||
|
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('url')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
mode === 'url' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-3.5 h-3.5" />
|
||||||
|
Lien URL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('upload')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
mode === 'upload' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative group">
|
||||||
|
{mode === 'url' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={name}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder || "https://images.unsplash.com/..."}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!value ? (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="cursor-pointer border-2 border-dashed border-slate-700 hover:border-indigo-500 rounded-xl p-8 flex flex-col items-center justify-center gap-3 bg-slate-900/50 hover:bg-slate-900 transition-all group"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center group-hover:bg-indigo-500/20 group-hover:text-indigo-400 transition-colors">
|
||||||
|
<Upload className="w-6 h-6 text-slate-400 group-hover:text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{isUploading ? "Upload en cours..." : "Cliquez pour uploader"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">PNG, JPG ou WEBP jusqu'à 5 Mo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
alt="Prévisualisation"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearImage}
|
||||||
|
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Hidden input for value to be sent via form if needed */}
|
||||||
|
<input type="hidden" name={name} value={value || ""} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value && mode === 'url' && (
|
||||||
|
<div className="mt-3 relative rounded-lg overflow-hidden aspect-video border border-slate-800">
|
||||||
|
<img src={value} alt="Preview" className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearImage}
|
||||||
|
className="absolute top-2 right-2 p-1.5 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-black transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition, useState } 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 } from 'lucide-react';
|
import { Loader2, ArrowLeft, Save, Video, FileText, Calendar } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { InterviewType } from '@prisma/client';
|
import { InterviewType, ContentStatus } from '@prisma/client';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import RichTextEditor from './RichTextEditor';
|
import RichTextEditor from './RichTextEditor';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
import { getAllTags } from '@/app/actions/taxonomies';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
@@ -17,10 +20,26 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [content, setContent] = useState(initialData?.content || '');
|
const [content, setContent] = useState(initialData?.content || '');
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
||||||
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
|
initialData?.publishedAt
|
||||||
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
||||||
|
: new Date().toISOString().slice(0, 16)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllTags().then(setAllExistingTags);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const publishedAtStr = formData.get('publishedAt') as string;
|
||||||
|
|
||||||
const data: any = {
|
const data: any = {
|
||||||
title: formData.get('title') as string,
|
title: formData.get('title') as string,
|
||||||
slug: formData.get('slug') as string,
|
slug: formData.get('slug') as string,
|
||||||
@@ -28,16 +47,23 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
companyName: formData.get('companyName') as string,
|
companyName: formData.get('companyName') as string,
|
||||||
role: formData.get('role') as string,
|
role: formData.get('role') as string,
|
||||||
type: formData.get('type') as InterviewType,
|
type: formData.get('type') as InterviewType,
|
||||||
thumbnailUrl: formData.get('thumbnailUrl') as string,
|
thumbnailUrl: thumbnailUrl, // Use state value
|
||||||
videoUrl: formData.get('videoUrl') as string || null,
|
videoUrl: formData.get('videoUrl') as string || null,
|
||||||
excerpt: formData.get('excerpt') as string,
|
excerpt: formData.get('excerpt') as string,
|
||||||
content: content || null,
|
content: content || null,
|
||||||
duration: formData.get('duration') as string || null,
|
duration: formData.get('duration') as string || null,
|
||||||
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
|
tags: tags, // Use state
|
||||||
metaTitle: formData.get('metaTitle') as string,
|
metaTitle: formData.get('metaTitle') as string,
|
||||||
metaDescription: formData.get('metaDescription') as string,
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
|
status: formData.get('status') as ContentStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!data.thumbnailUrl) {
|
||||||
|
toast.error("Une miniature est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = initialData
|
const result = initialData
|
||||||
? await updateInterview(initialData.id, data)
|
? await updateInterview(initialData.id, data)
|
||||||
@@ -45,7 +71,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");
|
||||||
@@ -57,7 +83,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">
|
||||||
@@ -68,7 +94,21 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
<div className="card space-y-6">
|
<div className="card space-y-6">
|
||||||
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={initialData?.status || 'PUBLISHED'}
|
||||||
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">Brouillon</option>
|
||||||
|
<option value="PUBLISHED">Publié</option>
|
||||||
|
<option value="ARCHIVED">Archivé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -126,24 +166,29 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
|
<label className="text-sm font-medium text-slate-400">
|
||||||
<input
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
||||||
name="duration"
|
</label>
|
||||||
defaultValue={initialData?.duration}
|
<div className="relative">
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||||
placeholder="Ex: 12 min"
|
<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>
|
</div>
|
||||||
|
|
||||||
<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">URL de la miniature (Thumbnail)</label>
|
<ImageUploader
|
||||||
<input
|
label="Miniature (Thumbnail)"
|
||||||
|
value={thumbnailUrl}
|
||||||
|
onChange={setThumbnailUrl}
|
||||||
name="thumbnailUrl"
|
name="thumbnailUrl"
|
||||||
defaultValue={initialData?.thumbnailUrl}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -154,6 +199,15 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
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/..."
|
||||||
/>
|
/>
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
|
||||||
|
<input
|
||||||
|
name="duration"
|
||||||
|
defaultValue={initialData?.duration}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: 12 min"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -194,12 +248,12 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
|
<TagInput
|
||||||
<input
|
value={tags}
|
||||||
name="tags"
|
onChange={setTags}
|
||||||
defaultValue={initialData?.tags?.join(', ') || ''}
|
suggestions={allExistingTags}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
label="Mots-clés (tags)"
|
||||||
placeholder="interview, succes, tech"
|
placeholder="tech, succes, interview..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
admin/src/components/ManualVerifyButton.tsx
Normal file
41
admin/src/components/ManualVerifyButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { verifyUserEmail } from '@/app/actions/user';
|
||||||
|
import { MailCheck, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManualVerifyButton({ userId }: Props) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleVerify = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await verifyUserEmail(userId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Email vérifié avec succès");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleVerify}
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-500/10 text-indigo-500 hover:bg-indigo-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-indigo-500/20"
|
||||||
|
title="Vérifier manuellement l'email"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MailCheck className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Vérifier Email
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,14 +9,17 @@ interface PlanEditModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
plan: any;
|
plan: any;
|
||||||
|
currentCycle: 'monthly' | 'yearly';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalProps) {
|
export default function PlanEditModal({ isOpen, onClose, plan, currentCycle }: PlanEditModalProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
priceXOF: plan.priceXOF,
|
priceXOF: plan.priceXOF,
|
||||||
|
yearlyPriceXOF: plan.yearlyPriceXOF || '',
|
||||||
priceEUR: plan.priceEUR,
|
priceEUR: plan.priceEUR,
|
||||||
|
yearlyPriceEUR: plan.yearlyPriceEUR || '',
|
||||||
description: plan.description,
|
description: plan.description,
|
||||||
offerLimit: plan.offerLimit.toString(),
|
offerLimit: plan.offerLimit.toString(),
|
||||||
recommended: plan.recommended,
|
recommended: plan.recommended,
|
||||||
@@ -71,7 +74,9 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
|
|||||||
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/50">
|
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/50">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-white font-serif">Modifier le plan {plan.tier}</h2>
|
<h2 className="text-xl font-bold text-white font-serif">Modifier le plan {plan.tier}</h2>
|
||||||
<p className="text-sm text-slate-400">Configurez l'affichage et les limites de ce forfait.</p>
|
<p className="text-sm text-slate-400">
|
||||||
|
Édition du mode <span className="text-indigo-400 font-bold uppercase">{currentCycle === 'yearly' ? 'Annuel' : 'Mensuel'}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
||||||
<X className="w-6 h-6" />
|
<X className="w-6 h-6" />
|
||||||
@@ -99,22 +104,48 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
|
|||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Texte)</label>
|
{currentCycle === 'monthly' ? (
|
||||||
<input
|
<>
|
||||||
value={formData.priceXOF}
|
<div className="space-y-2">
|
||||||
onChange={(e) => setFormData({...formData, priceXOF: e.target.value})}
|
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Mensuel)</label>
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
<input
|
||||||
/>
|
value={formData.priceXOF}
|
||||||
</div>
|
onChange={(e) => setFormData({...formData, priceXOF: e.target.value})}
|
||||||
<div className="space-y-2">
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Texte)</label>
|
/>
|
||||||
<input
|
</div>
|
||||||
value={formData.priceEUR}
|
<div className="space-y-2">
|
||||||
onChange={(e) => setFormData({...formData, priceEUR: e.target.value})}
|
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Mensuel)</label>
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
<input
|
||||||
/>
|
value={formData.priceEUR}
|
||||||
</div>
|
onChange={(e) => setFormData({...formData, priceEUR: e.target.value})}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Annuel)</label>
|
||||||
|
<input
|
||||||
|
value={formData.yearlyPriceXOF}
|
||||||
|
onChange={(e) => setFormData({...formData, yearlyPriceXOF: e.target.value})}
|
||||||
|
placeholder="ex: 45 000 FCFA"
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors shadow-[0_0_10px_rgba(16,185,129,0.1)] border-emerald-500/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Annuel)</label>
|
||||||
|
<input
|
||||||
|
value={formData.yearlyPriceEUR}
|
||||||
|
onChange={(e) => setFormData({...formData, yearlyPriceEUR: e.target.value})}
|
||||||
|
placeholder="ex: 69 €"
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors shadow-[0_0_10px_rgba(16,185,129,0.1)] border-emerald-500/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -14,19 +14,23 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Settings,
|
Settings,
|
||||||
Globe,
|
Globe,
|
||||||
FileText
|
FileText,
|
||||||
|
Briefcase,
|
||||||
|
Tags,
|
||||||
|
Image as ImageIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
|
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
|
||||||
{ name: 'Entrepreneurs', icon: ShieldCheck, href: '/entrepreneurs' },
|
{ name: 'Utilisateurs', icon: Users, href: '/users' },
|
||||||
{ name: 'Clients', icon: Users, href: '/clients' },
|
|
||||||
{ 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: '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' },
|
||||||
];
|
];
|
||||||
@@ -65,7 +69,7 @@ export default function Sidebar() {
|
|||||||
<span>{item.name}</span>
|
<span>{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.name === 'Entrepreneurs' && pendingCount > 0 && (
|
{item.name === 'Utilisateurs' && pendingCount > 0 && (
|
||||||
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shadow-lg group-hover:scale-110 transition-transform">
|
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shadow-lg group-hover:scale-110 transition-transform">
|
||||||
{pendingCount}
|
{pendingCount}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
292
admin/src/components/SlideForm.tsx
Normal file
292
admin/src/components/SlideForm.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createSlide, updateSlide, searchBusinesses } from '@/app/actions/slides';
|
||||||
|
import { Loader2, ArrowLeft, Save, Search, Check, X, ImageIcon, Briefcase } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SlideForm({ initialData }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [type, setType] = useState(initialData?.type || 'CUSTOM');
|
||||||
|
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
||||||
|
const [isActive, setIsActive] = useState(initialData?.isActive ?? true);
|
||||||
|
|
||||||
|
// Business search state
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
const [selectedBusiness, setSelectedBusiness] = useState<any>(initialData?.business || null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchQuery.length > 1) {
|
||||||
|
const delayDebounceFn = setTimeout(() => {
|
||||||
|
setIsSearching(true);
|
||||||
|
searchBusinesses(searchQuery).then(results => {
|
||||||
|
setSearchResults(results);
|
||||||
|
setIsSearching(false);
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(delayDebounceFn);
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
type,
|
||||||
|
businessId: type === 'BUSINESS' ? selectedBusiness?.id : null,
|
||||||
|
title: formData.get('title') as string,
|
||||||
|
subtitle: formData.get('subtitle') as string,
|
||||||
|
imageUrl: type === 'CUSTOM' ? imageUrl : (selectedBusiness?.coverUrl || selectedBusiness?.logoUrl || ''),
|
||||||
|
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
|
||||||
|
order: parseInt(formData.get('order') as string) || 0,
|
||||||
|
isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'BUSINESS' && !data.businessId) {
|
||||||
|
toast.error("Veuillez sélectionner une entreprise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'CUSTOM' && !data.imageUrl) {
|
||||||
|
toast.error("Une image est requise pour une slide personnalisée");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = initialData
|
||||||
|
? await updateSlide(initialData.id, data)
|
||||||
|
: await createSlide(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(initialData ? "Slide mise à jour" : "Slide créée avec succès");
|
||||||
|
router.push('/slides');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto pb-20">
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/slides" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{initialData ? 'Modifier la Slide' : 'Nouvelle Slide'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Type de Slide</h2>
|
||||||
|
<div className="flex bg-slate-900 p-1 rounded-lg border border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setType('BUSINESS')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all ${
|
||||||
|
type === 'BUSINESS' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Briefcase className="w-4 h-4" />
|
||||||
|
Entreprise
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setType('CUSTOM')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all ${
|
||||||
|
type === 'CUSTOM' ? 'bg-emerald-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
Personnalisé
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === 'BUSINESS' ? (
|
||||||
|
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Rechercher une entreprise</label>
|
||||||
|
|
||||||
|
{selectedBusiness ? (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-indigo-500/10 border border-indigo-500/20 rounded-xl">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img src={selectedBusiness.logoUrl} className="w-12 h-12 rounded-lg object-cover" alt="" />
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-bold">{selectedBusiness.name}</div>
|
||||||
|
<div className="text-xs text-indigo-400">{selectedBusiness.location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedBusiness(null)}
|
||||||
|
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
key="search-query-input"
|
||||||
|
type="text"
|
||||||
|
value={searchQuery || ""}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Tapez le nom d'une entreprise..."
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSearching && (
|
||||||
|
<div className="absolute right-3 top-3">
|
||||||
|
<Loader2 className="w-5 h-5 text-indigo-500 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="absolute z-10 w-full mt-2 bg-slate-800 border border-slate-700 rounded-xl shadow-2xl overflow-hidden max-h-60 overflow-y-auto">
|
||||||
|
{searchResults.map((biz) => (
|
||||||
|
<button
|
||||||
|
key={biz.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBusiness(biz);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 p-3 hover:bg-slate-700 text-left transition-colors border-b border-slate-700 last:border-0"
|
||||||
|
>
|
||||||
|
<img src={biz.logoUrl} className="w-10 h-10 rounded object-cover" alt="" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">{biz.name}</div>
|
||||||
|
<div className="text-xs text-slate-400">{biz.location}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-900/50 rounded-lg border border-slate-800 italic text-xs text-slate-500">
|
||||||
|
L'image, le titre et le lien seront automatiquement récupérés depuis la fiche de l'entreprise.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Titre</label>
|
||||||
|
<input
|
||||||
|
key="custom-title-input"
|
||||||
|
name="title"
|
||||||
|
defaultValue={initialData?.title || ""}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
placeholder="Ex: Découvrez l'artisanat local"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
|
||||||
|
<input
|
||||||
|
key="custom-subtitle-input"
|
||||||
|
name="subtitle"
|
||||||
|
defaultValue={initialData?.subtitle || ""}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
placeholder="Ex: Des produits uniques faits main"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Lien (URL)</label>
|
||||||
|
<input
|
||||||
|
key="custom-link-input"
|
||||||
|
name="linkUrl"
|
||||||
|
defaultValue={initialData?.linkUrl || ""}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
||||||
|
placeholder="Ex: /annuaire ou https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageUploader
|
||||||
|
label="Image de fond"
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={setImageUrl}
|
||||||
|
name="imageUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Paramètres d'Affichage</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Ordre d'affichage</label>
|
||||||
|
<input
|
||||||
|
key="order-input"
|
||||||
|
name="order"
|
||||||
|
type="number"
|
||||||
|
defaultValue={initialData?.order ?? 0}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">Les slides sont triées par ordre croissant.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-900 rounded-xl border border-slate-800">
|
||||||
|
<span className="text-sm font-medium text-slate-300">Statut de la slide</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsActive(!isActive)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
|
||||||
|
isActive ? 'bg-emerald-600' : 'bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${
|
||||||
|
isActive ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
{initialData ? 'Enregistrer les modifications' : 'Créer la slide'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
admin/src/components/TagInput.tsx
Normal file
180
admin/src/components/TagInput.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, KeyboardEvent, useEffect, useRef } from 'react';
|
||||||
|
import { X, Hash, Search, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
suggestions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagInput({ value, onChange, label, placeholder, suggestions = [] }: Props) {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputValue.trim()) {
|
||||||
|
const filtered = suggestions
|
||||||
|
.filter(tag =>
|
||||||
|
tag.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||||
|
!value.includes(tag.toLowerCase())
|
||||||
|
)
|
||||||
|
.slice(0, 10);
|
||||||
|
setFilteredSuggestions(filtered);
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
} else if (showSuggestions && suggestions.length > 0) {
|
||||||
|
// Show popular/all suggestions when empty but focused
|
||||||
|
const filtered = suggestions
|
||||||
|
.filter(tag => !value.includes(tag.toLowerCase()))
|
||||||
|
.slice(0, 8);
|
||||||
|
setFilteredSuggestions(filtered);
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
} else {
|
||||||
|
setFilteredSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}, [inputValue, suggestions, value, showSuggestions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addTag = (tagToAdd?: string) => {
|
||||||
|
const tag = (tagToAdd || inputValue).trim().toLowerCase();
|
||||||
|
if (tag && !value.includes(tag)) {
|
||||||
|
onChange([...value, tag]);
|
||||||
|
}
|
||||||
|
setInputValue('');
|
||||||
|
// Keep focus if we added via suggestion
|
||||||
|
if (tagToAdd) setShowSuggestions(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tagToRemove: string) => {
|
||||||
|
onChange(value.filter(tag => tag !== tagToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
|
||||||
|
addTag(filteredSuggestions[selectedIndex]);
|
||||||
|
} else {
|
||||||
|
addTag();
|
||||||
|
}
|
||||||
|
} else if (e.key === ',' || e.key === ';') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!showSuggestions && suggestions.length > 0) {
|
||||||
|
setShowSuggestions(true);
|
||||||
|
} else {
|
||||||
|
setSelectedIndex(prev => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev));
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev > -1 ? prev - 1 : -1));
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
|
||||||
|
removeTag(value[value.length - 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 relative" ref={containerRef}>
|
||||||
|
{label && <label className="text-sm font-medium text-slate-400">{label}</label>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-h-[48px] p-1.5 bg-slate-900 border border-slate-700 rounded-lg flex flex-wrap gap-2 items-center focus-within:border-indigo-500 transition-colors cursor-text"
|
||||||
|
onClick={() => {
|
||||||
|
const input = containerRef.current?.querySelector('input');
|
||||||
|
input?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-1 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 px-2.5 py-1 rounded-md text-sm font-medium animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<Hash className="w-3 h-3 opacity-50" />
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeTag(tag);
|
||||||
|
}}
|
||||||
|
className="hover:text-red-400 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setShowSuggestions(true)}
|
||||||
|
placeholder={value.length === 0 ? (placeholder || "Ajouter des tags...") : ""}
|
||||||
|
className="flex-1 bg-transparent border-none outline-none text-white text-sm min-w-[120px] p-1 placeholder:text-slate-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions Popover */}
|
||||||
|
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
<div className="p-2 border-b border-slate-700 bg-slate-900/50 flex items-center justify-between">
|
||||||
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
|
{inputValue ? <Search className="w-3 h-3" /> : <TrendingUp className="w-3 h-3" />}
|
||||||
|
{inputValue ? 'Suggestions de recherche' : 'Mots-clés suggérés'}
|
||||||
|
</p>
|
||||||
|
{!inputValue && <span className="text-[9px] text-slate-600 font-medium">Top {filteredSuggestions.length}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||||
|
{filteredSuggestions.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
addTag(suggestion);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
|
||||||
|
index === selectedIndex ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Hash className={`w-3.5 h-3.5 ${index === selectedIndex ? 'text-white/50' : 'text-slate-500'}`} />
|
||||||
|
{suggestion}
|
||||||
|
</span>
|
||||||
|
{index === selectedIndex && (
|
||||||
|
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded font-bold uppercase">Entrée</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-slate-500 italic">
|
||||||
|
Appuyez sur Entrée ou virgule pour ajouter un tag.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { PrismaPg } from '@prisma/adapter-pg'
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
// Updated: 2026-04-18 14:28 (Forced reload for PricingPlan model)
|
// Updated: 2026-05-10T17:21 (Forced reload for OneShotPlan model)
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL!
|
const connectionString = process.env.DATABASE_URL!
|
||||||
@@ -11,10 +11,10 @@ function makePrisma() {
|
|||||||
return new PrismaClient({ adapter })
|
return new PrismaClient({ adapter })
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as { prisma_v3: PrismaClient }
|
const globalForPrisma = globalThis as unknown as { prisma_v4: PrismaClient }
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma_v3 || makePrisma()
|
export const prisma = globalForPrisma.prisma_v4 || makePrisma()
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v3 = prisma
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v4 = prisma
|
||||||
|
|
||||||
export default prisma
|
export default prisma
|
||||||
|
|||||||
133
app/HomeClient.tsx
Normal file
133
app/HomeClient.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
|
||||||
|
import { generateSlug } from '@/lib/utils';
|
||||||
|
import { Business, BlogPost } from '@/types';
|
||||||
|
import BusinessCard from '@/components/BusinessCard';
|
||||||
|
import { useUser } from '@/components/UserProvider';
|
||||||
|
import HomeSlider from '@/components/HomeSlider';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialFeatured: Business[];
|
||||||
|
initialPosts: BlogPost[];
|
||||||
|
initialCategories: any[];
|
||||||
|
initialSlides: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialSlides }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, settings } = useUser();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [locationTerm, setLocationTerm] = useState('');
|
||||||
|
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
|
||||||
|
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
||||||
|
const [categories, setCategories] = useState<any[]>(initialCategories);
|
||||||
|
const [slides, setSlides] = useState<any[]>(initialSlides);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingPosts, setLoadingPosts] = useState(false);
|
||||||
|
|
||||||
|
const handleSearch = (searchTerm: string, locationTerm: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (searchTerm) params.append('q', searchTerm);
|
||||||
|
if (locationTerm) params.append('location', locationTerm);
|
||||||
|
router.push(`/annuaire?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Dynamic Hero Slider */}
|
||||||
|
<HomeSlider slides={slides} onSearch={handleSearch} />
|
||||||
|
|
||||||
|
{/* Featured Categories */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-10 font-serif text-center">Secteurs en vedette</h2>
|
||||||
|
<div className="flex flex-wrap justify-center gap-4 sm:gap-6">
|
||||||
|
{(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
|
||||||
|
<Link
|
||||||
|
key={idx}
|
||||||
|
href={`/annuaire?category=${encodeURIComponent(cat)}`}
|
||||||
|
className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center w-[calc(50%-0.5rem)] sm:w-48 lg:w-56 flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
|
||||||
|
<Briefcase className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm sm:text-base line-clamp-2">{cat}</h3>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured Businesses */}
|
||||||
|
<div className="bg-gray-50 py-16">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-end mb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 font-serif">Entreprises à la une</h2>
|
||||||
|
<p className="text-gray-500 mt-2">Découvrez les pépites de notre communauté.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/annuaire" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
|
||||||
|
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{featuredBusinesses.map(biz => (
|
||||||
|
<BusinessCard key={biz.id} business={biz} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Blog Section */}
|
||||||
|
{settings?.homeBlogShow !== false && (
|
||||||
|
<div className="py-16">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-end mb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
|
||||||
|
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/actualites" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
|
||||||
|
Voir les actualités <ArrowRight className="w-4 h-4 ml-1" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{posts
|
||||||
|
.filter(post => {
|
||||||
|
if (settings?.homeBlogSelection === 'manual') {
|
||||||
|
return settings.homeBlogIds?.includes(post.id);
|
||||||
|
}
|
||||||
|
if (settings?.homeBlogSelection === 'category') {
|
||||||
|
return post.tags?.some((tag: string) => settings.homeBlogCategories?.includes(tag));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.slice(0, settings?.homeBlogCount || 3)
|
||||||
|
.map(post => (
|
||||||
|
<Link key={post.id} href={`/actualites/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
|
||||||
|
<div className="h-48 overflow-hidden">
|
||||||
|
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-brand-600 transition-colors">{post.title}</h3>
|
||||||
|
<p className="text-gray-500 text-sm line-clamp-3 mb-4">{post.excerpt}</p>
|
||||||
|
<div className="flex items-center justify-between mt-auto pt-4 border-t border-gray-50">
|
||||||
|
<span className="text-xs font-medium text-gray-400">{new Date(post.date).toLocaleDateString('fr-FR')}</span>
|
||||||
|
<span className="text-brand-600 font-bold text-xs">Lire la suite →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomeClient;
|
||||||
42
app/actions/events.ts
Normal file
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: ContentStatus.PENDING, // Always pending for user submission
|
||||||
|
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/evenements");
|
||||||
|
return { success: true, event };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create event:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création de l'événement" };
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/actions/news.ts
Normal file
40
app/actions/news.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function createNews(data: {
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
imageUrl: string;
|
||||||
|
tags?: string[];
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
|
||||||
|
const post = await prisma.blogPost.create({
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
excerpt: data.excerpt,
|
||||||
|
content: data.content,
|
||||||
|
author: data.author,
|
||||||
|
imageUrl: data.imageUrl,
|
||||||
|
tags: data.tags || [],
|
||||||
|
status: ContentStatus.PENDING, // Always pending for user submission
|
||||||
|
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/blog");
|
||||||
|
return { success: true, post };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create news:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création de l'actualité" };
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/actions/upload.ts
Normal file
28
app/actions/upload.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
export async function uploadImage(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
if (!file) {
|
||||||
|
return { success: false, error: 'Aucun fichier fourni' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert file to buffer
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
|
// Convert to Base64
|
||||||
|
const base64 = buffer.toString('base64');
|
||||||
|
const mimeType = file.type || 'image/jpeg';
|
||||||
|
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||||
|
|
||||||
|
// Return the data URL directly (it will be saved in the database)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url: dataUrl
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during image conversion:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la conversion de l\'image' };
|
||||||
|
}
|
||||||
|
}
|
||||||
236
app/actualites/[id]/page.tsx
Normal file
236
app/actualites/[id]/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { ArrowLeft, ArrowRight, User, Calendar, Share2 } from 'lucide-react';
|
||||||
|
import { sanitizeHTML } from '../../../lib/sanitize';
|
||||||
|
import { prisma } from '../../../lib/prisma';
|
||||||
|
import BlogShareButtons from '../../../components/BlogShareButtons';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style spécifique pour empêcher strictement la coupure des mots tout en justifiant le texte
|
||||||
|
const noBreakStyle = `
|
||||||
|
#blog-content,
|
||||||
|
#blog-content p,
|
||||||
|
#blog-content li,
|
||||||
|
#blog-content span,
|
||||||
|
#blog-content strong {
|
||||||
|
word-break: normal !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
hyphens: none !important;
|
||||||
|
line-break: normal !important;
|
||||||
|
text-align: justify !important;
|
||||||
|
text-justify: inter-word !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sécurité supplémentaire pour les navigateurs mobiles */
|
||||||
|
.prose * {
|
||||||
|
word-break: normal !important;
|
||||||
|
hyphens: none !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const post = await prisma.blogPost.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ id: id }, { slug: id }],
|
||||||
|
publishedAt: { lte: new Date() },
|
||||||
|
status: 'PUBLISHED'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) return { title: 'Article non trouvé' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: post.metaTitle || post.title,
|
||||||
|
description: post.metaDescription || post.excerpt,
|
||||||
|
keywords: post.tags,
|
||||||
|
openGraph: {
|
||||||
|
title: post.metaTitle || post.title,
|
||||||
|
description: post.metaDescription || post.excerpt,
|
||||||
|
images: [post.imageUrl],
|
||||||
|
type: 'article',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogPostPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
const post = await prisma.blogPost.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ id: id }, { slug: id }],
|
||||||
|
publishedAt: { lte: new Date() },
|
||||||
|
status: 'PUBLISHED'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Articles en rapport : par tags communs, sinon les plus récents
|
||||||
|
let relatedPosts = await prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: { lte: new Date() },
|
||||||
|
id: { not: post.id },
|
||||||
|
tags: { hasSome: post.tags }
|
||||||
|
},
|
||||||
|
take: 3,
|
||||||
|
orderBy: { date: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compléter avec les articles récents si nécessaire
|
||||||
|
if (relatedPosts.length < 2) {
|
||||||
|
const excludedIds = [post.id, ...relatedPosts.map(p => p.id)];
|
||||||
|
const additionalPosts = await prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: { lte: new Date() },
|
||||||
|
id: { notIn: excludedIds }
|
||||||
|
},
|
||||||
|
take: 3 - relatedPosts.length,
|
||||||
|
orderBy: { date: 'desc' }
|
||||||
|
});
|
||||||
|
relatedPosts = [...relatedPosts, ...additionalPosts];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="bg-gray-50 min-h-screen py-12 relative">
|
||||||
|
{/* Injection du style de force pour le texte */}
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: noBreakStyle }} />
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 flex gap-4 lg:gap-8 justify-center relative">
|
||||||
|
{/* Conteneur principal de l'article */}
|
||||||
|
<div className="w-[calc(100%-3rem)] sm:w-full max-w-3xl relative">
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link href="/actualites" className="inline-flex items-center text-gray-500 hover:text-brand-600 text-sm font-medium transition-colors">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6 md:p-10 border border-gray-100 relative z-10">
|
||||||
|
{/* Image d'en-tête */}
|
||||||
|
<div className="w-full h-64 md:h-80 relative mb-8 rounded-lg overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={post.imageUrl}
|
||||||
|
alt={post.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header de l'article */}
|
||||||
|
<header className="mb-8 border-b border-gray-100 pb-8">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<User className="w-4 h-4 mr-1 text-brand-500" />
|
||||||
|
{post.author}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
|
||||||
|
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
{post.tags.length > 0 ? (
|
||||||
|
post.tags.map(tag => (
|
||||||
|
<span key={tag} className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||||
|
Article
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Contenu de l'article */}
|
||||||
|
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
|
||||||
|
{/* Résumé / Lead */}
|
||||||
|
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Corps du texte avec ID pour le contrôle CSS */}
|
||||||
|
<div
|
||||||
|
id="blog-content"
|
||||||
|
className="mt-6"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeHTML(post.content) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Partage / Footer */}
|
||||||
|
<div className="mt-12 pt-8 border-t border-gray-100 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||||
|
<p className="text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
||||||
|
<Link
|
||||||
|
href="/actualites"
|
||||||
|
className="inline-flex items-center text-brand-600 hover:text-brand-700 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Lire d'autres articles
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar flottante verticale à droite (suit l'article et s'arrête à la fin) */}
|
||||||
|
<div className="w-10 shrink-0 relative">
|
||||||
|
<div className="sticky top-32 z-50">
|
||||||
|
<BlogShareButtons title={post.title} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Articles en rapport */}
|
||||||
|
{relatedPosts.length > 0 && (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 mt-16 pt-16 border-t border-gray-200">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-serif font-bold text-gray-900 mb-8">Articles en rapport</h2>
|
||||||
|
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{relatedPosts.map((rPost) => (
|
||||||
|
<Link
|
||||||
|
key={rPost.id}
|
||||||
|
href={`/actualites/${rPost.slug || rPost.id}`}
|
||||||
|
className="group bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="aspect-video relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={rPost.imageUrl}
|
||||||
|
alt={rPost.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/5 group-hover:bg-transparent transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-600 bg-brand-50 px-2 py-0.5 rounded">
|
||||||
|
{rPost.tags?.[0] || 'Article'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{new Date(rPost.date).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 group-hover:text-brand-600 transition-colors line-clamp-2 leading-snug">
|
||||||
|
{rPost.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-500 line-clamp-2">
|
||||||
|
{rPost.excerpt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ export default async function BlogPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
posts = await prisma.blogPost.findMany({
|
posts = await prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -20,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>
|
||||||
|
|
||||||
@@ -34,7 +40,7 @@ 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-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} />
|
||||||
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
|
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight } from 'lucide-react';
|
import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight } from 'lucide-react';
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -13,16 +14,21 @@ interface Props {
|
|||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
const interview = await prisma.interview.findFirst({
|
const interview = await prisma.interview.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id }, { slug: id }]
|
OR: [{ id }, { slug: id }],
|
||||||
|
publishedAt: { lte: now },
|
||||||
|
status: 'PUBLISHED'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const event = !interview ? await prisma.event.findFirst({
|
const event = !interview ? await prisma.event.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id }, { slug: id }]
|
OR: [{ id }, { slug: id }],
|
||||||
|
publishedAt: { lte: now },
|
||||||
|
status: 'PUBLISHED'
|
||||||
}
|
}
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
@@ -46,18 +52,23 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
|
|
||||||
export default async function AfroLifeDetailPage({ params }: Props) {
|
export default async function AfroLifeDetailPage({ params }: Props) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
// 1. Try to find an interview
|
// 1. Try to find an interview
|
||||||
const interview = await prisma.interview.findFirst({
|
const interview = await prisma.interview.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id }, { slug: id }]
|
OR: [{ id }, { slug: id }],
|
||||||
|
publishedAt: { lte: now },
|
||||||
|
status: 'PUBLISHED'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. If not found, try to find an event
|
// 2. If not found, try to find an event
|
||||||
const event = !interview ? await prisma.event.findFirst({
|
const event = !interview ? await prisma.event.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id }, { slug: id }]
|
OR: [{ id }, { slug: id }],
|
||||||
|
publishedAt: { lte: now },
|
||||||
|
status: 'PUBLISHED'
|
||||||
}
|
}
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
@@ -156,12 +167,12 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 break-words overflow-hidden">
|
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600">
|
||||||
{isEvent ? (
|
{isEvent ? (
|
||||||
<div dangerouslySetInnerHTML={{ __html: (event as any).description }} />
|
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} />
|
||||||
) : (
|
) : (
|
||||||
!isVideo && (interview as any).content ? (
|
!isVideo && (interview as any).content ? (
|
||||||
<div dangerouslySetInnerHTML={{ __html: (interview as any).content }} />
|
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((interview as any).content) }} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xl font-serif italic text-gray-500 border-l-4 border-brand-500 pl-6 py-2">
|
<p className="text-xl font-serif italic text-gray-500 border-l-4 border-brand-500 pl-6 py-2">
|
||||||
{(interview as any).excerpt}
|
{(interview as any).excerpt}
|
||||||
@@ -194,4 +205,3 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Play, FileText, Clock, Mic, Calendar } from 'lucide-react';
|
import { Play, FileText, Clock, Mic, Calendar } from 'lucide-react';
|
||||||
|
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 EventTimeline from '../../components/EventTimeline';
|
import EventTimeline from '../../components/EventTimeline';
|
||||||
@@ -13,13 +14,22 @@ interface Props {
|
|||||||
|
|
||||||
export default async function AfroLifePage({ searchParams }: Props) {
|
export default async function AfroLifePage({ searchParams }: Props) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const filter = params.type as string || 'ALL';
|
const rawFilter = params.type as string || 'ALL';
|
||||||
|
const allowedFilters = ['ALL', 'EVENT', 'VIDEO', 'ARTICLE'];
|
||||||
|
const filter = allowedFilters.includes(rawFilter) ? rawFilter : 'ALL';
|
||||||
|
|
||||||
let items: any[] = [];
|
let items: any[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (filter === 'EVENT') {
|
if (filter === 'EVENT') {
|
||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: now
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { date: 'desc' }
|
orderBy: { date: 'desc' }
|
||||||
});
|
});
|
||||||
items = events.map(e => ({
|
items = events.map(e => ({
|
||||||
@@ -31,7 +41,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
|
|||||||
duration: new Date(e.date).toLocaleDateString('fr-FR')
|
duration: new Date(e.date).toLocaleDateString('fr-FR')
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
const where: any = {};
|
const where: any = {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: now
|
||||||
|
}
|
||||||
|
};
|
||||||
if (filter !== 'ALL') {
|
if (filter !== 'ALL') {
|
||||||
where.type = filter as InterviewType;
|
where.type = filter as InterviewType;
|
||||||
}
|
}
|
||||||
@@ -44,6 +59,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
|
|||||||
// If 'ALL', we might want to also fetch events and mix them
|
// If 'ALL', we might want to also fetch events and mix them
|
||||||
if (filter === 'ALL') {
|
if (filter === 'ALL') {
|
||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: now
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
take: 10
|
take: 10
|
||||||
});
|
});
|
||||||
@@ -119,54 +140,59 @@ export default async function AfroLifePage({ searchParams }: Props) {
|
|||||||
<EventTimeline events={items} />
|
<EventTimeline events={items} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{items.map(item => (
|
{items.map(item => {
|
||||||
<Link href={`/afrolife/${(item as any).slug || item.id}`} key={item.id} className="group block h-full">
|
const isPastEvent = item.type === 'EVENT' && new Date(item.date) < new Date();
|
||||||
<div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300">
|
|
||||||
<img
|
|
||||||
src={item.thumbnailUrl}
|
|
||||||
alt={item.title}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
|
||||||
|
|
||||||
{/* Type Badge */}
|
return (
|
||||||
<div className="absolute top-4 left-4">
|
<Link href={`/afrolife/${(item as any).slug || item.id}`} key={item.id} className={`group block h-full transition-all ${isPastEvent ? 'opacity-60 grayscale-[0.5]' : ''}`}>
|
||||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${
|
<div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300">
|
||||||
item.type === 'VIDEO' ? 'bg-red-600/90' :
|
<img
|
||||||
item.type === 'EVENT' ? 'bg-amber-600/90' : 'bg-blue-600/90'
|
src={item.thumbnailUrl}
|
||||||
}`}>
|
alt={item.title}
|
||||||
{item.type === 'VIDEO' ? <Play className="w-3 h-3 mr-1 fill-current" /> :
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
item.type === 'EVENT' ? <Calendar className="w-3 h-3 mr-1" /> : <Mic className="w-3 h-3 mr-1" />}
|
/>
|
||||||
{item.type === 'VIDEO' ? 'Vidéo' :
|
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
||||||
item.type === 'EVENT' ? 'Événement' : 'Interview'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Play Icon Overlay for Videos */}
|
{/* Type Badge */}
|
||||||
{item.type === 'VIDEO' && (
|
<div className="absolute top-4 left-4">
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${
|
||||||
<div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50">
|
isPastEvent ? 'bg-gray-600/90' :
|
||||||
<Play className="w-8 h-8 text-white fill-current ml-1" />
|
item.type === 'VIDEO' ? 'bg-red-600/90' :
|
||||||
</div>
|
item.type === 'EVENT' ? 'bg-amber-600/90' : 'bg-blue-600/90'
|
||||||
|
}`}>
|
||||||
|
{item.type === 'VIDEO' ? <Play className="w-3 h-3 mr-1 fill-current" /> :
|
||||||
|
item.type === 'EVENT' ? <Calendar className="w-3 h-3 mr-1" /> : <Mic className="w-3 h-3 mr-1" />}
|
||||||
|
{item.type === 'VIDEO' ? 'Vidéo' :
|
||||||
|
item.type === 'EVENT' ? (isPastEvent ? 'Événement Passé' : 'Événement') : 'Interview'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-6 px-2">
|
{/* Play Icon Overlay for Videos */}
|
||||||
<div className="flex items-center text-xs text-gray-500 mb-3 space-x-3">
|
{item.type === 'VIDEO' && (
|
||||||
<span className="font-semibold text-brand-600 uppercase">{item.guestName}</span>
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
|
<div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50">
|
||||||
<span className="truncate max-w-[100px]">{item.companyName}</span>
|
<Play className="w-8 h-8 text-white fill-current ml-1" />
|
||||||
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
|
</div>
|
||||||
<span className="flex items-center flex-nowrap"><Clock className="w-3 h-3 mr-1 shrink-0"/> {item.duration || 'N/A'}</span>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-serif font-bold text-gray-900 mb-2 group-hover:text-brand-600 transition-colors leading-tight line-clamp-2">
|
|
||||||
{item.title}
|
<div className="pt-6 px-2">
|
||||||
</h3>
|
<div className="flex items-center text-xs text-gray-500 mb-3 space-x-3">
|
||||||
<div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: item.excerpt }} />
|
<span className={`font-semibold uppercase ${isPastEvent ? 'text-gray-400' : 'text-brand-600'}`}>{item.guestName}</span>
|
||||||
</div>
|
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
|
||||||
</Link>
|
<span className="truncate max-w-[100px]">{item.companyName}</span>
|
||||||
))}
|
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
|
||||||
|
<span className="flex items-center flex-nowrap"><Clock className="w-3 h-3 mr-1 shrink-0"/> {item.duration || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className={`text-xl font-serif font-bold mb-2 transition-colors leading-tight line-clamp-2 ${isPastEvent ? 'text-gray-500' : 'text-gray-900 group-hover:text-brand-600'}`}>
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: sanitizeHTML(item.excerpt) }} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
666
app/annuaire/[id]/BusinessDetailClient.tsx
Normal file
666
app/annuaire/[id]/BusinessDetailClient.tsx
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Twitter, Play, Share2, Send, Award, Quote, MessageCircle, Lock, Loader2, Link2, ShoppingBag, Heart } from 'lucide-react';
|
||||||
|
import { Business, OfferType, Rating } from '@/types';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { useUser } from '@/components/UserProvider';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialBusiness: Business;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BusinessDetailClient = ({ initialBusiness }: Props) => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user } = useUser();
|
||||||
|
const [business, setBusiness] = useState<Business>(initialBusiness);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [existingConversationId, setExistingConversationId] = useState<string | null>(null);
|
||||||
|
const [isShareOpen, setIsShareOpen] = useState(false);
|
||||||
|
const [selectedOffer, setSelectedOffer] = useState<any | null>(null);
|
||||||
|
const [userRating, setUserRating] = useState<number | null>(null);
|
||||||
|
const [hoverRating, setHoverRating] = useState<number | null>(null);
|
||||||
|
const [isRating, setIsRating] = useState(false);
|
||||||
|
const [ratings, setRatings] = useState<Rating[]>([]);
|
||||||
|
const [reviewComment, setReviewComment] = useState('');
|
||||||
|
const [userRatingStatus, setUserRatingStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED' | null>(null);
|
||||||
|
const [loadingRatings, setLoadingRatings] = useState(false);
|
||||||
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||||
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||||
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
|
const [isTogglingFavorite, setIsTogglingFavorite] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = user && business && user.id === business.ownerId;
|
||||||
|
const contactRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [contactForm, setContactForm] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
|
||||||
|
|
||||||
|
const handleOrder = (offer: any) => {
|
||||||
|
setContactForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
message: `Bonjour, je souhaiterais commander ou avoir plus d'informations sur votre offre : "${offer.title}". Pouvez-vous me recontacter ?`
|
||||||
|
}));
|
||||||
|
setSelectedOffer(null);
|
||||||
|
contactRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
setTimeout(() => {
|
||||||
|
const textarea = document.getElementById('contact-message');
|
||||||
|
if (textarea) textarea.focus();
|
||||||
|
}, 800);
|
||||||
|
toast.success("Message préparé ! Vous pouvez l'envoyer ci-dessous.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const businessOffers = useMemo(() => {
|
||||||
|
if (!business) return [];
|
||||||
|
if (business.offers && business.offers.length > 0) return business.offers;
|
||||||
|
return [];
|
||||||
|
}, [business]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadExtras = async () => {
|
||||||
|
// Track view event if not owner
|
||||||
|
if (!user || user.id !== business.ownerId) {
|
||||||
|
fetch('/api/analytics', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'BUSINESS_VIEW',
|
||||||
|
path: window.location.pathname,
|
||||||
|
label: business.name,
|
||||||
|
metadata: {
|
||||||
|
businessId: business.id,
|
||||||
|
businessName: business.name,
|
||||||
|
userId: user?.id || 'anonymous'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}).catch(console.error);
|
||||||
|
|
||||||
|
fetch(`/api/businesses/${business.id}/view`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: user ? { 'x-user-id': user.id } : {}
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user rating if logged in
|
||||||
|
if (user) {
|
||||||
|
try {
|
||||||
|
const ratingRes = await fetch(`/api/businesses/${business.id}/rating/me`, {
|
||||||
|
headers: { 'x-user-id': user.id }
|
||||||
|
});
|
||||||
|
const ratingData = await ratingRes.json();
|
||||||
|
if (ratingData.rating) {
|
||||||
|
setUserRating(ratingData.rating);
|
||||||
|
setReviewComment(ratingData.comment || '');
|
||||||
|
setUserRatingStatus(ratingData.status);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
|
||||||
|
// Fetch favorite status
|
||||||
|
try {
|
||||||
|
const favRes = await fetch('/api/favorites', {
|
||||||
|
headers: { 'x-user-id': user.id }
|
||||||
|
});
|
||||||
|
const favData = await favRes.json();
|
||||||
|
if (Array.isArray(favData)) {
|
||||||
|
setIsFavorited(favData.some((f: any) => f.businessId === business.id));
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all ratings
|
||||||
|
try {
|
||||||
|
setLoadingRatings(true);
|
||||||
|
const ratingsRes = await fetch(`/api/businesses/${business.id}/ratings`, {
|
||||||
|
headers: user ? { 'x-user-id': user.id } : {}
|
||||||
|
});
|
||||||
|
if (ratingsRes.ok) {
|
||||||
|
const ratingsData = await ratingsRes.json();
|
||||||
|
setRatings(ratingsData);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); } finally { setLoadingRatings(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
loadExtras();
|
||||||
|
}, [business.id, user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkExistingConversation = async () => {
|
||||||
|
if (!user || user.id === 'undefined' || !business?.id) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/conversations?businessId=${business.id}`, {
|
||||||
|
headers: { 'x-user-id': user.id }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
setExistingConversationId(data[0].id);
|
||||||
|
}
|
||||||
|
} catch (error) { console.error(error); }
|
||||||
|
};
|
||||||
|
if (user && business) checkExistingConversation();
|
||||||
|
}, [user, business]);
|
||||||
|
|
||||||
|
const getEmbedUrl = (url: string) => {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
return (match && match[2].length === 11) ? `https://www.youtube.com/embed/${match[2]}` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const videoEmbedUrl = business.videoUrl ? getEmbedUrl(business.videoUrl) : null;
|
||||||
|
|
||||||
|
const handleTrackEvent = async (type: string, label?: string) => {
|
||||||
|
if (!business?.id || (user && user.id === business.ownerId)) return;
|
||||||
|
try {
|
||||||
|
fetch('/api/analytics', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type,
|
||||||
|
path: window.location.pathname,
|
||||||
|
label: label || business.name,
|
||||||
|
metadata: {
|
||||||
|
businessId: business.id,
|
||||||
|
businessName: business.name,
|
||||||
|
userId: user?.id || 'anonymous'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContactSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!user) {
|
||||||
|
toast.error('Veuillez vous connecter pour envoyer un message');
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormStatus('sending');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-id': user.id
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
businessId: business.id,
|
||||||
|
content: contactForm.message
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.error) {
|
||||||
|
handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Message Sent');
|
||||||
|
setFormStatus('success');
|
||||||
|
toast.success('Message envoyé ! Redirection vers votre messagerie...');
|
||||||
|
setContactForm({ ...contactForm, message: '' });
|
||||||
|
setTimeout(() => router.push('/dashboard?view=messages'), 2000);
|
||||||
|
} else {
|
||||||
|
toast.error(data.error);
|
||||||
|
setFormStatus('idle');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Erreur lors de l'envoi");
|
||||||
|
setFormStatus('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = (platform: string) => {
|
||||||
|
const url = encodeURIComponent(window.location.href);
|
||||||
|
const text = encodeURIComponent(`Découvrez ${business.name} sur Afrohub`);
|
||||||
|
let shareLink = '';
|
||||||
|
switch(platform) {
|
||||||
|
case 'facebook': shareLink = `https://www.facebook.com/sharer/sharer.php?u=${url}`; break;
|
||||||
|
case 'twitter': shareLink = `https://twitter.com/intent/tweet?url=${url}&text=${text}`; break;
|
||||||
|
case 'linkedin': shareLink = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`; break;
|
||||||
|
case 'copy':
|
||||||
|
navigator.clipboard.writeText(window.location.href);
|
||||||
|
toast.success("Lien copié !");
|
||||||
|
setIsShareOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shareLink) window.open(shareLink, '_blank', 'width=600,height=400');
|
||||||
|
setIsShareOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRate = async (value: number) => {
|
||||||
|
if (!user) { toast.error('Connectez-vous pour voter'); router.push('/login'); return; }
|
||||||
|
if (isOwner) { toast.error("Vous ne pouvez pas noter votre propre entreprise"); return; }
|
||||||
|
if (isRating) return;
|
||||||
|
setIsRating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/businesses/${business.id}/rate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
|
||||||
|
body: JSON.stringify({ value, comment: reviewComment })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.error) {
|
||||||
|
setUserRating(value);
|
||||||
|
setBusiness(prev => ({ ...prev, rating: data.rating, ratingCount: data.ratingCount }));
|
||||||
|
toast.success('Merci pour votre vote !');
|
||||||
|
handleTrackEvent('BUSINESS_RATING_CAST', `Stars: ${value}`);
|
||||||
|
const rRes = await fetch(`/api/businesses/${business.id}/ratings`);
|
||||||
|
if (rRes.ok) setRatings(await rRes.json());
|
||||||
|
} else toast.error(data.error);
|
||||||
|
} catch (error) { toast.error("Erreur vote"); } finally { setIsRating(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReply = async (ratingId: string) => {
|
||||||
|
if (!user || !isOwner || !replyText.trim()) return;
|
||||||
|
setIsSubmittingReply(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/ratings/${ratingId}/reply`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
|
||||||
|
body: JSON.stringify({ reply: replyText })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("Réponse publiée !");
|
||||||
|
setReplyingTo(null);
|
||||||
|
setReplyText('');
|
||||||
|
const rRes = await fetch(`/api/businesses/${business.id}/ratings`);
|
||||||
|
if (rRes.ok) setRatings(await rRes.json());
|
||||||
|
} else toast.error("Erreur réponse");
|
||||||
|
} catch (e) { toast.error("Erreur connexion"); } finally { setIsSubmittingReply(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavorite = async () => {
|
||||||
|
if (!user) { toast.error("Connectez-vous"); router.push('/login'); return; }
|
||||||
|
setIsTogglingFavorite(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
|
||||||
|
body: JSON.stringify({ businessId: business.id })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.error) {
|
||||||
|
setIsFavorited(data.favorited);
|
||||||
|
toast.success(data.favorited ? 'Ajouté aux favoris' : 'Retiré des favoris');
|
||||||
|
}
|
||||||
|
} catch (error) { toast.error('Erreur'); } finally { setIsTogglingFavorite(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 min-h-screen pb-12">
|
||||||
|
{/* Header Banner */}
|
||||||
|
<div className="h-48 md:h-64 bg-gray-900 relative overflow-hidden">
|
||||||
|
{business.coverUrl ? (
|
||||||
|
<img
|
||||||
|
src={business.coverUrl}
|
||||||
|
alt="Couverture"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
||||||
|
style={{
|
||||||
|
objectPosition: business.coverPosition || '50% 50%',
|
||||||
|
transform: `scale(${business.coverZoom || 1})`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-gray-900 to-gray-800 opacity-100">
|
||||||
|
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] pointer-events-none"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex flex-col justify-between py-6 relative z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit group active:scale-95"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" /> Retour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 relative z-10">
|
||||||
|
<div className="bg-white rounded-xl shadow-lg border border-gray-100">
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
{/* Profile Header */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||||
|
<div className="w-32 h-32 md:w-40 md:h-40 rounded-xl bg-white p-1 shadow-md -mt-16 md:-mt-24 border border-gray-100 flex-shrink-0 relative z-20">
|
||||||
|
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-lg bg-gray-50" />
|
||||||
|
{business.isFeatured && (
|
||||||
|
<div className="absolute -top-3 -right-3 bg-yellow-400 text-yellow-900 rounded-full p-2 shadow-md" title="Afroshine">
|
||||||
|
<Award className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 w-full">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-3xl font-serif font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
{business.name}
|
||||||
|
</h1>
|
||||||
|
{business.verified && <span title="Entreprise Vérifiée"><CheckCircle className="w-6 h-6 text-blue-500" /></span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-brand-600 font-medium mt-1 uppercase tracking-wide text-sm">{business.category}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 relative">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsShareOpen(!isShareOpen)}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4 mr-2" /> Partager
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isShareOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-2xl py-3 border border-gray-100 z-50 animate-in fade-in zoom-in duration-200">
|
||||||
|
<button onClick={() => handleShare('facebook')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-600">
|
||||||
|
<Facebook className="w-4 h-4 mr-3" /> Facebook
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleShare('twitter')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-black">
|
||||||
|
<Twitter className="w-4 h-4 mr-3" /> X (Twitter)
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleShare('copy')} className="w-full text-left px-4 py-2.5 text-sm font-medium text-brand-600 hover:bg-brand-50 flex items-center">
|
||||||
|
<Link2 className="w-4 h-4 mr-3" /> Copier le lien
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleFavorite}
|
||||||
|
disabled={isTogglingFavorite}
|
||||||
|
className={`inline-flex items-center px-4 py-2 border shadow-sm text-sm font-medium rounded-md transition-all active:scale-95 ${isFavorited ? 'bg-red-500 border-red-500 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
<Heart className={`w-4 h-4 mr-2 ${isFavorited ? 'fill-current' : ''}`} />
|
||||||
|
{isFavorited ? 'Favori' : 'Mettre en favori'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="#contact-form" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700">
|
||||||
|
Contacter
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 mt-6 text-sm text-gray-500 border-t border-gray-100 pt-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<MapPin className="w-4 h-4 mr-1 text-gray-400" />
|
||||||
|
{business.location}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-5 h-5 ${star <= Math.round(business.rating) ? 'text-yellow-400 fill-current' : 'text-gray-300'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-gray-900">{business.rating.toFixed(1)}</span>
|
||||||
|
<span className="text-gray-400">({business.ratingCount} votes • {business.viewCount} vues)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8">
|
||||||
|
<div className="lg:col-span-2 space-y-8">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||||
|
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4">À propos</h2>
|
||||||
|
<p className="text-gray-600 leading-relaxed whitespace-pre-line">{business.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{videoEmbedUrl && (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||||
|
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4 flex items-center">
|
||||||
|
<Play className="w-5 h-5 mr-2 text-brand-600" /> Présentation Vidéo
|
||||||
|
</h2>
|
||||||
|
<iframe src={videoEmbedUrl} className="w-full h-64 md:h-96 rounded-lg" allowFullScreen></iframe>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{businessOffers.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||||
|
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Produits & Services</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
{businessOffers.map(offer => (
|
||||||
|
<div key={offer.id} className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-all cursor-pointer group" onClick={() => handleOrder(offer)}>
|
||||||
|
<div className="h-40 bg-gray-100 relative">
|
||||||
|
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="font-bold text-gray-900">{offer.title}</h3>
|
||||||
|
<p className="text-brand-600 font-bold mt-1">{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Avis et Commentaires */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||||
|
<h2 className="text-xl font-bold font-serif text-gray-900 flex items-center">
|
||||||
|
<Star className="w-5 h-5 mr-2 text-yellow-400 fill-current" /> Avis et Commentaires
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!isOwner && (
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
onMouseEnter={() => setHoverRating(star)}
|
||||||
|
onMouseLeave={() => setHoverRating(null)}
|
||||||
|
onClick={() => handleRate(star)}
|
||||||
|
className="transition-transform active:scale-125 focus:outline-none"
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`w-8 h-8 ${star <= (hoverRating || userRating || 0) ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 font-medium italic">
|
||||||
|
{userRating ? "Merci pour votre note !" : "Cliquez sur une étoile pour voter"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOwner && (
|
||||||
|
<div className="mb-10 bg-gray-50 rounded-xl p-5 border border-gray-100">
|
||||||
|
<p className="text-sm font-bold text-gray-700 mb-3">Laissez un commentaire</p>
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
value={reviewComment}
|
||||||
|
onChange={(e) => setReviewComment(e.target.value)}
|
||||||
|
placeholder="Que pensez-vous de cette entreprise ? (Qualité, service...)"
|
||||||
|
className="w-full bg-white border border-gray-200 rounded-lg p-4 text-sm focus:ring-2 focus:ring-brand-500 outline-none h-24 resize-none shadow-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRate(userRating || 5)}
|
||||||
|
disabled={isRating}
|
||||||
|
className="absolute bottom-3 right-3 bg-brand-600 text-white px-4 py-1.5 rounded-md text-xs font-bold hover:bg-brand-700 transition-all flex items-center gap-2 shadow-md"
|
||||||
|
>
|
||||||
|
{isRating ? <Loader2 className="w-3 h-3 animate-spin" /> : "Publier"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{loadingRatings ? (
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<Loader2 className="w-8 h-8 text-brand-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : ratings.length > 0 ? (
|
||||||
|
ratings.map((rating) => (
|
||||||
|
<div key={rating.id} className="border-b border-gray-50 last:border-0 pb-6">
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-brand-50 flex items-center justify-center text-brand-700 font-bold text-sm">
|
||||||
|
{(rating as any).user?.name?.charAt(0) || 'U'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900 text-sm">{(rating as any).user?.name}</p>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-3 h-3 ${star <= rating.value ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="text-[10px] text-gray-400 ml-2 font-medium">
|
||||||
|
{new Date(rating.createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed italic ml-13">
|
||||||
|
<Quote className="w-3 h-3 text-brand-200 inline mr-1" />
|
||||||
|
{rating.comment || "Pas de commentaire"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Reply section for owner */}
|
||||||
|
{isOwner && !rating.reply && (
|
||||||
|
<div className="mt-4 ml-13">
|
||||||
|
{replyingTo === rating.id ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder="Votre réponse..."
|
||||||
|
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm outline-none focus:ring-1 focus:ring-brand-500 h-20"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleReply(rating.id)} disabled={isSubmittingReply} className="bg-brand-600 text-white px-3 py-1.5 rounded text-xs font-bold">Répondre</button>
|
||||||
|
<button onClick={() => setReplyingTo(null)} className="text-gray-500 text-xs">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setReplyingTo(rating.id)} className="text-brand-600 text-xs font-bold hover:underline">Répondre au client</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rating.reply && (
|
||||||
|
<div className="mt-4 ml-13 bg-brand-50/50 p-4 rounded-lg border-l-2 border-brand-300">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<MessageCircle className="w-3 h-3 text-brand-600" />
|
||||||
|
<span className="text-[10px] font-bold text-brand-700 uppercase">Réponse de {business.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 text-sm italic">{rating.reply}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-10 bg-gray-50 rounded-xl border border-dashed border-gray-200">
|
||||||
|
<Star className="w-10 h-10 text-gray-200 mx-auto mb-2" />
|
||||||
|
<p className="text-gray-500 text-sm">Soyez le premier à donner votre avis !</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||||
|
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Coordonnées</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{business.websiteUrl && (
|
||||||
|
<a href={business.websiteUrl.startsWith('http') ? business.websiteUrl : `https://${business.websiteUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
|
||||||
|
<Globe className="w-5 h-5 text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Site Web</p>
|
||||||
|
<p className="text-sm font-medium truncate">{business.websiteUrl.replace(/^https?:\/\//, '')}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{business.showEmail && business.contactEmail && (
|
||||||
|
<a href={`mailto:${business.contactEmail}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
|
||||||
|
<Mail className="w-5 h-5 text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Email</p>
|
||||||
|
<p className="text-sm font-medium truncate">{business.contactEmail}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{business.showPhone && business.contactPhone && (
|
||||||
|
<a href={`tel:${business.contactPhone}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
|
||||||
|
<Phone className="w-5 h-5 text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Téléphone</p>
|
||||||
|
<p className="text-sm font-medium truncate">{business.contactPhone}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{business.showSocials && business.socialLinks && (
|
||||||
|
<div className="pt-4 border-t border-gray-50">
|
||||||
|
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-3">Réseaux Sociaux</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{business.socialLinks.facebook && (
|
||||||
|
<a href={business.socialLinks.facebook} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-all">
|
||||||
|
<Facebook className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{business.socialLinks.linkedin && (
|
||||||
|
<a href={business.socialLinks.linkedin} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-700 hover:bg-blue-50 transition-all">
|
||||||
|
<Linkedin className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{business.socialLinks.instagram && (
|
||||||
|
<a href={business.socialLinks.instagram} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-pink-600 hover:bg-pink-50 transition-all">
|
||||||
|
<Instagram className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{business.socialLinks.twitter && (
|
||||||
|
<a href={business.socialLinks.twitter} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-black hover:bg-gray-100 transition-all">
|
||||||
|
<Twitter className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="contact-form" ref={contactRef} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||||
|
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Contacter l'entreprise</h2>
|
||||||
|
<form onSubmit={handleContactSubmit} className="space-y-4">
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
value={contactForm.message}
|
||||||
|
onChange={(e) => setContactForm({...contactForm, message: e.target.value})}
|
||||||
|
placeholder="Votre message..."
|
||||||
|
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm focus:ring-2 focus:ring-brand-500 outline-none h-32"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={formStatus === 'sending'} className="w-full bg-brand-600 text-white py-3 rounded-lg font-bold hover:bg-brand-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
{formStatus === 'sending' ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
|
||||||
|
Envoyer le message
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BusinessDetailClient;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,30 +4,39 @@
|
|||||||
import React, { useState, useEffect, useMemo, Suspense } from 'react';
|
import React, { useState, useEffect, useMemo, Suspense } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Search, Loader2 } from 'lucide-react';
|
import { Search, Loader2 } from 'lucide-react';
|
||||||
import { CATEGORIES, Business, Country } from '../../types';
|
import { Business, Country } from '../../types';
|
||||||
import BusinessCard from '../../components/BusinessCard';
|
import BusinessCard from '../../components/BusinessCard';
|
||||||
import AnnuaireHero from '../../components/AnnuaireHero';
|
import AnnuaireHero from '../../components/AnnuaireHero';
|
||||||
|
import { useUser } from '../../components/UserProvider';
|
||||||
|
|
||||||
const AnnuairePageContent = () => {
|
const AnnuairePageContent = () => {
|
||||||
|
const { user } = useUser();
|
||||||
const [businesses, setBusinesses] = useState<Business[]>([]);
|
const [businesses, setBusinesses] = useState<Business[]>([]);
|
||||||
const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null);
|
const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null);
|
||||||
const [countries, setCountries] = useState<Country[]>([]);
|
const [countries, setCountries] = useState<Country[]>([]);
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filterCategory, setFilterCategory] = useState('All');
|
const [filterCategory, setFilterCategory] = useState('All');
|
||||||
const [filterCountry, setFilterCountry] = useState('All');
|
const [filterCountry, setFilterCountry] = useState('All');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [locationQuery, setLocationQuery] = useState('');
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
// 1. Fetch Countries
|
// 1. Fetch Metadata (Countries & Categories)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCountries = async () => {
|
const fetchMetadata = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/countries');
|
const [cRes, catRes] = await Promise.all([
|
||||||
const data = await res.json();
|
fetch('/api/countries'),
|
||||||
if (Array.isArray(data)) setCountries(data);
|
fetch('/api/categories')
|
||||||
|
]);
|
||||||
|
const cData = await cRes.json();
|
||||||
|
const catData = await catRes.json();
|
||||||
|
if (Array.isArray(cData)) setCountries(cData);
|
||||||
|
if (Array.isArray(catData)) setCategories(catData);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
fetchCountries();
|
fetchMetadata();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 2. Fetch Featured Headliner once
|
// 2. Fetch Featured Headliner once
|
||||||
@@ -35,7 +44,10 @@ const AnnuairePageContent = () => {
|
|||||||
const fetchHeadliner = async () => {
|
const fetchHeadliner = async () => {
|
||||||
try {
|
try {
|
||||||
// Disable caching to ensure we get fresh data and more variety
|
// Disable caching to ensure we get fresh data and more variety
|
||||||
const res = await fetch('/api/businesses?featured=true', { cache: 'no-store' });
|
const res = await fetch('/api/businesses?featured=true', {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: user ? { 'x-user-id': user.id } : {}
|
||||||
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
// Truly random selection from the pool of featured businesses
|
// Truly random selection from the pool of featured businesses
|
||||||
@@ -58,8 +70,11 @@ const AnnuairePageContent = () => {
|
|||||||
if (filterCategory !== 'All') params.append('category', filterCategory);
|
if (filterCategory !== 'All') params.append('category', filterCategory);
|
||||||
if (filterCountry !== 'All') params.append('countryId', filterCountry);
|
if (filterCountry !== 'All') params.append('countryId', filterCountry);
|
||||||
if (searchQuery) params.append('q', searchQuery);
|
if (searchQuery) params.append('q', searchQuery);
|
||||||
|
if (locationQuery) params.append('location', locationQuery);
|
||||||
|
|
||||||
const res = await fetch(`/api/businesses?${params.toString()}`);
|
const res = await fetch(`/api/businesses?${params.toString()}`, {
|
||||||
|
headers: user ? { 'x-user-id': user.id } : {}
|
||||||
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setBusinesses(data);
|
setBusinesses(data);
|
||||||
@@ -73,12 +88,15 @@ const AnnuairePageContent = () => {
|
|||||||
|
|
||||||
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
|
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [filterCategory, searchQuery, filterCountry]);
|
}, [filterCategory, searchQuery, filterCountry, locationQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = searchParams.get('q');
|
const q = searchParams.get('q');
|
||||||
if (q) setSearchQuery(q);
|
if (q) setSearchQuery(q);
|
||||||
|
|
||||||
|
const loc = searchParams.get('location');
|
||||||
|
if (loc) setLocationQuery(loc);
|
||||||
|
|
||||||
const cat = searchParams.get('category');
|
const cat = searchParams.get('category');
|
||||||
if (cat) setFilterCategory(cat);
|
if (cat) setFilterCategory(cat);
|
||||||
|
|
||||||
@@ -111,6 +129,17 @@ const AnnuairePageContent = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Ville / Localisation</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={locationQuery}
|
||||||
|
onChange={(e) => setLocationQuery(e.target.value)}
|
||||||
|
className="w-full border-gray-300 rounded-md shadow-sm focus:ring-brand-500 focus:border-brand-500 sm:text-sm p-2 border"
|
||||||
|
placeholder="Ex: Abidjan, Dakar..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Pays</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Pays</label>
|
||||||
<select
|
<select
|
||||||
@@ -139,17 +168,17 @@ const AnnuairePageContent = () => {
|
|||||||
/>
|
/>
|
||||||
<label htmlFor="cat-all" className="ml-3 text-sm text-gray-600">Toutes</label>
|
<label htmlFor="cat-all" className="ml-3 text-sm text-gray-600">Toutes</label>
|
||||||
</div>
|
</div>
|
||||||
{CATEGORIES.map(cat => (
|
{categories.map(cat => (
|
||||||
<div key={cat} className="flex items-center">
|
<div key={cat.id} className="flex items-center">
|
||||||
<input
|
<input
|
||||||
id={`cat-${cat}`}
|
id={`cat-${cat.id}`}
|
||||||
name="category"
|
name="category"
|
||||||
type="radio"
|
type="radio"
|
||||||
checked={filterCategory === cat}
|
checked={filterCategory === cat.id || filterCategory === cat.name}
|
||||||
onChange={() => setFilterCategory(cat)}
|
onChange={() => setFilterCategory(cat.id)}
|
||||||
className="focus:ring-brand-500 h-4 w-4 text-brand-600 border-gray-300"
|
className="focus:ring-brand-500 h-4 w-4 text-brand-600 border-gray-300"
|
||||||
/>
|
/>
|
||||||
<label htmlFor={`cat-${cat}`} className="ml-3 text-sm text-gray-600 truncate" title={cat}>{cat}</label>
|
<label htmlFor={`cat-${cat.id}`} className="ml-3 text-sm text-gray-600 truncate" title={cat.name}>{cat.name}</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
7
app/api/auth/csrf/route.ts
Normal file
7
app/api/auth/csrf/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { generateCsrfToken } from '@/lib/csrf';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const token = await generateCsrfToken();
|
||||||
|
return NextResponse.json({ csrfToken: token });
|
||||||
|
}
|
||||||
89
app/api/auth/forgot-password/route.ts
Normal file
89
app/api/auth/forgot-password/route.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextResponse, NextRequest } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import { sendEmail } from '@/lib/mail';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// CSRF Protection
|
||||||
|
const isOriginValid = verifyOrigin(req);
|
||||||
|
const isCsrfValid = await verifyCsrfToken(req);
|
||||||
|
|
||||||
|
if (!isOriginValid || !isCsrfValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Protection CSRF : Requête invalide' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { email } = await req.json();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json({ error: 'Email est requis' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Pour des raisons de sécurité, on ne dit pas si l'utilisateur existe ou non
|
||||||
|
return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer un token
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiry = new Date(Date.now() + 3600000); // 1 heure
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
resetToken: token,
|
||||||
|
resetTokenExpiry: expiry,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
|
||||||
|
|
||||||
|
// Get site settings for email template
|
||||||
|
const settings = await prisma.siteSetting.findUnique({
|
||||||
|
where: { id: 'singleton' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const siteName = settings?.siteName || 'Afrohub';
|
||||||
|
const subject = settings?.resetPasswordSubject || 'Réinitialisation de votre mot de passe - Afrohub';
|
||||||
|
|
||||||
|
let html = settings?.resetPasswordTemplate || `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
|
||||||
|
<p>Bonjour ${user.name},</p>
|
||||||
|
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
|
||||||
|
</div>
|
||||||
|
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 ${siteName}. Tous droits réservés.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Replace placeholders
|
||||||
|
html = html
|
||||||
|
.replace(/{name}/g, user.name)
|
||||||
|
.replace(/{resetUrl}/g, resetUrl)
|
||||||
|
.replace(/{siteName}/g, siteName);
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error in forgot-password:', error);
|
||||||
|
return NextResponse.json({ error: 'Une erreur est survenue lors de la demande' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
// CSRF Protection
|
||||||
|
const isOriginValid = verifyOrigin(request);
|
||||||
|
const isCsrfValid = await verifyCsrfToken(request);
|
||||||
|
|
||||||
|
if (!isOriginValid || !isCsrfValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Protection CSRF : Requête invalide' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { email, password } = await request.json();
|
const { email, password } = await request.json();
|
||||||
|
|
||||||
@@ -35,6 +48,14 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if email is verified
|
||||||
|
if (!user.emailVerified) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Veuillez vérifier votre adresse email avant de vous connecter.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Omit password from response
|
// Omit password from response
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
// CSRF Protection
|
||||||
|
const isOriginValid = verifyOrigin(request);
|
||||||
|
const isCsrfValid = await verifyCsrfToken(request);
|
||||||
|
|
||||||
|
if (!isOriginValid || !isCsrfValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Protection CSRF : Requête invalide' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, email, password } = await request.json();
|
const { name, email, password } = await request.json();
|
||||||
|
|
||||||
@@ -36,16 +50,60 @@ export async function POST(request: NextRequest) {
|
|||||||
// Hash password
|
// Hash password
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Generate verification token
|
||||||
|
const verificationToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
role: 'VISITOR' // Default role
|
role: 'VISITOR', // Default role
|
||||||
|
verificationToken,
|
||||||
|
emailVerified: false,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send verification email
|
||||||
|
try {
|
||||||
|
const settings = await prisma.siteSetting.findUnique({
|
||||||
|
where: { id: 'singleton' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const siteName = settings?.siteName || 'Afrohub';
|
||||||
|
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
|
||||||
|
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
|
||||||
|
|
||||||
|
let html = settings?.verifyEmailTemplate || `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
|
||||||
|
<p>Bonjour ${name},</p>
|
||||||
|
<p>Merci de vous être inscrit sur ${siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Replace placeholders
|
||||||
|
html = html
|
||||||
|
.replace(/{name}/g, name)
|
||||||
|
.replace(/{verifyUrl}/g, verifyUrl)
|
||||||
|
.replace(/{siteName}/g, siteName);
|
||||||
|
|
||||||
|
const { sendEmail } = await import('@/lib/mail');
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
console.error('Failed to send verification email:', mailError);
|
||||||
|
// We don't fail registration if mail fails, but maybe we should?
|
||||||
|
// For now, just log it.
|
||||||
|
}
|
||||||
|
|
||||||
// Omit password from response
|
// Omit password from response
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
|||||||
111
app/api/auth/resend-verification/route.ts
Normal file
111
app/api/auth/resend-verification/route.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// CSRF Protection
|
||||||
|
const isOriginValid = verifyOrigin(request);
|
||||||
|
const isCsrfValid = await verifyCsrfToken(request);
|
||||||
|
|
||||||
|
if (!isOriginValid || !isCsrfValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Protection CSRF : Requête invalide' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { email } = await request.json();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email requis' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// For security, same message even if user doesn't exist
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Si un compte existe pour cet email et n\'est pas encore vérifié, un nouvel email de vérification a été envoyé.' },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.emailVerified) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Ce compte est déjà vérifié.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new verification token
|
||||||
|
const verificationToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
// Update user with new token
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { verificationToken }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send verification email
|
||||||
|
try {
|
||||||
|
const settings = await prisma.siteSetting.findUnique({
|
||||||
|
where: { id: 'singleton' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const siteName = settings?.siteName || 'Afrohub';
|
||||||
|
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
|
||||||
|
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
|
||||||
|
|
||||||
|
let html = settings?.verifyEmailTemplate || `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
|
||||||
|
<p>Bonjour {name},</p>
|
||||||
|
<p>Vous avez demandé le renvoi de l'email de vérification pour votre compte sur {siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Replace placeholders
|
||||||
|
html = html
|
||||||
|
.replace(/{name}/g, user.name)
|
||||||
|
.replace(/{verifyUrl}/g, verifyUrl)
|
||||||
|
.replace(/{siteName}/g, siteName);
|
||||||
|
|
||||||
|
const { sendEmail } = await import('@/lib/mail');
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
console.error('Failed to resend verification email:', mailError);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erreur lors de l\'envoi de l\'email' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Un nouvel email de vérification a été envoyé.' },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Resend verification error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Une erreur est survenue' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/auth/reset-password/route.ts
Normal file
54
app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse, NextRequest } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// CSRF Protection
|
||||||
|
const isOriginValid = verifyOrigin(request);
|
||||||
|
const isCsrfValid = await verifyCsrfToken(request);
|
||||||
|
|
||||||
|
if (!isOriginValid || !isCsrfValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Protection CSRF : Requête invalide' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { token, password } = await request.json();
|
||||||
|
|
||||||
|
if (!token || !password) {
|
||||||
|
return NextResponse.json({ error: 'Token et mot de passe sont requis' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
resetToken: token,
|
||||||
|
resetTokenExpiry: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Token invalide ou expiré' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
password: hashedPassword,
|
||||||
|
resetToken: null,
|
||||||
|
resetTokenExpiry: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Votre mot de passe a été mis à jour avec succès' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error in reset-password:', error);
|
||||||
|
return NextResponse.json({ error: 'Une erreur est survenue lors de la réinitialisation' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/api/auth/verify/route.ts
Normal file
38
app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=Token manquant', baseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { verificationToken: token }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=Token invalide ou expiré', baseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
emailVerified: true,
|
||||||
|
verificationToken: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL('/login?verified=true', baseUrl));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Verification error:', error);
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
||||||
|
return NextResponse.redirect(new URL('/login?error=Erreur de vérification', baseUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ export async function PATCH(
|
|||||||
try {
|
try {
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
if (body.title && body.slug === undefined) {
|
||||||
|
const { generateSlug } = await import('@/lib/utils')
|
||||||
|
body.slug = generateSlug(body.title)
|
||||||
|
}
|
||||||
const post = await prisma.blogPost.update({ where: { id }, data: body })
|
const post = await prisma.blogPost.update({ where: { id }, data: body })
|
||||||
return NextResponse.json(post)
|
return NextResponse.json(post)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { generateSlug } from '@/lib/utils'
|
||||||
|
|
||||||
// GET /api/blog
|
// GET /api/blog
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const posts = await prisma.blogPost.findMany({
|
const posts = await prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
})
|
})
|
||||||
return NextResponse.json(posts)
|
return NextResponse.json(posts)
|
||||||
@@ -18,6 +25,9 @@ export async function GET() {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
if (!body.slug && body.title) {
|
||||||
|
body.slug = generateSlug(body.title)
|
||||||
|
}
|
||||||
const post = await prisma.blogPost.create({ data: body })
|
const post = await prisma.blogPost.create({ data: body })
|
||||||
return NextResponse.json(post, { status: 201 })
|
return NextResponse.json(post, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { USER_SAFE_SELECT, getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> }
|
type Params = { params: Promise<{ id: string }> }
|
||||||
|
|
||||||
// GET /api/businesses/[id]
|
// GET /api/businesses/[id]
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
const user = await getAuthUser(request)
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const business = await prisma.business.findFirst({
|
const business = await prisma.business.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -17,10 +23,21 @@ export async function GET(
|
|||||||
{ slug: id }
|
{ slug: id }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
include: { owner: true, offers: true },
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: USER_SAFE_SELECT
|
||||||
|
},
|
||||||
|
offers: true
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (!business) return NextResponse.json({ error: 'Non trouvé' }, { status: 404 })
|
if (!business) return NextResponse.json({ error: 'Non trouvé' }, { status: 404 })
|
||||||
return NextResponse.json(business)
|
|
||||||
|
// Mask technical IDs
|
||||||
|
const { id: bizId, ownerId, ...rest } = business
|
||||||
|
return NextResponse.json({
|
||||||
|
...rest,
|
||||||
|
id: business.slug || bizId
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GET /api/businesses/[id] error:', error)
|
console.error('GET /api/businesses/[id] error:', error)
|
||||||
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
||||||
@@ -38,7 +55,12 @@ export async function PATCH(
|
|||||||
const business = await prisma.business.update({
|
const business = await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: body,
|
data: body,
|
||||||
include: { owner: true, offers: true },
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: USER_SAFE_SELECT
|
||||||
|
},
|
||||||
|
offers: true
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return NextResponse.json(business)
|
return NextResponse.json(business)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { USER_SAFE_SELECT, getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// GET /api/businesses — List all businesses (Mock + DB)
|
// GET /api/businesses — List all businesses (Mock + DB)
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -7,6 +8,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const category = searchParams.get('category')
|
const category = searchParams.get('category')
|
||||||
const q = searchParams.get('q')?.toLowerCase()
|
const q = searchParams.get('q')?.toLowerCase()
|
||||||
|
const location = searchParams.get('location')?.toLowerCase()
|
||||||
const featured = searchParams.get('featured')
|
const featured = searchParams.get('featured')
|
||||||
const countryId = searchParams.get('countryId')
|
const countryId = searchParams.get('countryId')
|
||||||
|
|
||||||
@@ -18,26 +20,69 @@ export async function GET(request: NextRequest) {
|
|||||||
isSuspended: false
|
isSuspended: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (category && category !== 'All') where.category = category
|
|
||||||
|
// Build the conditions array
|
||||||
|
const conditions: any[] = [];
|
||||||
|
|
||||||
|
if (category && category !== 'All') {
|
||||||
|
const isId = /^[a-z0-9-]+$/.test(category) && category.length > 20;
|
||||||
|
if (isId) {
|
||||||
|
where.categoryId = category
|
||||||
|
} else {
|
||||||
|
where.category = category
|
||||||
|
}
|
||||||
|
}
|
||||||
if (featured === 'true') where.isFeatured = true
|
if (featured === 'true') where.isFeatured = true
|
||||||
if (countryId) where.countryId = countryId
|
if (countryId) where.countryId = countryId
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
where.OR = [
|
conditions.push({
|
||||||
{ name: { contains: q, mode: 'insensitive' } },
|
OR: [
|
||||||
{ description: { contains: q, mode: 'insensitive' } },
|
{ name: { contains: q, mode: 'insensitive' } },
|
||||||
// Simplified tags check
|
{ description: { contains: q, mode: 'insensitive' } },
|
||||||
{ tags: { has: q } }
|
{ city: { contains: q, mode: 'insensitive' } },
|
||||||
]
|
{ location: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ tags: { has: q } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
conditions.push({
|
||||||
|
OR: [
|
||||||
|
{ city: { contains: location, mode: 'insensitive' } },
|
||||||
|
{ location: { contains: location, mode: 'insensitive' } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
where.AND = conditions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbBusinesses = await prisma.business.findMany({
|
const dbBusinesses = await prisma.business.findMany({
|
||||||
where,
|
where,
|
||||||
include: { owner: true, offers: true },
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: USER_SAFE_SELECT
|
||||||
|
},
|
||||||
|
offers: true
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Return DB results
|
// 2. Map results to mask technical IDs
|
||||||
return NextResponse.json(dbBusinesses)
|
const safeBusinesses = dbBusinesses.map(biz => {
|
||||||
|
const { id, ownerId, ...rest } = biz
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
// Return slug as the primary identifier if available,
|
||||||
|
// otherwise return a masked version or keep it but it's now "safe"
|
||||||
|
id: biz.slug || id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(safeBusinesses)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GET /api/businesses error:', error)
|
console.error('GET /api/businesses error:', error)
|
||||||
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
||||||
@@ -61,11 +106,16 @@ export async function POST(request: NextRequest) {
|
|||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
slug: data.slug || null,
|
slug: data.slug || null,
|
||||||
category: data.category || "Autre",
|
category: data.category || "Autre",
|
||||||
|
categoryId: (data.categoryId && data.categoryId !== 'Autre') ? data.categoryId : null,
|
||||||
|
suggestedCategory: data.suggestedCategory || null,
|
||||||
location: data.location || "",
|
location: data.location || "",
|
||||||
countryId: data.countryId || null,
|
countryId: data.countryId || null,
|
||||||
city: data.city || "",
|
city: data.city || "",
|
||||||
description: data.description || "",
|
description: data.description || "",
|
||||||
logoUrl: data.logoUrl || "https://picsum.photos/200/200?random=default",
|
logoUrl: data.logoUrl || "https://picsum.photos/200/200?random=default",
|
||||||
|
coverUrl: data.coverUrl || null,
|
||||||
|
coverPosition: data.coverPosition || "50% 50%",
|
||||||
|
coverZoom: data.coverZoom ? parseFloat(data.coverZoom) : 1.0,
|
||||||
videoUrl: data.videoUrl || null,
|
videoUrl: data.videoUrl || null,
|
||||||
contactEmail: data.contactEmail || "",
|
contactEmail: data.contactEmail || "",
|
||||||
contactPhone: data.contactPhone || null,
|
contactPhone: data.contactPhone || null,
|
||||||
@@ -120,7 +170,11 @@ export async function POST(request: NextRequest) {
|
|||||||
business = await prisma.business.update({
|
business = await prisma.business.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: cleanData,
|
data: cleanData,
|
||||||
include: { owner: true }
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: USER_SAFE_SELECT
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// When creating, we add the ownerId to the sanitized cleanData
|
// When creating, we add the ownerId to the sanitized cleanData
|
||||||
@@ -129,7 +183,11 @@ export async function POST(request: NextRequest) {
|
|||||||
...cleanData,
|
...cleanData,
|
||||||
ownerId: ownerId
|
ownerId: ownerId
|
||||||
},
|
},
|
||||||
include: { owner: true }
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: USER_SAFE_SELECT
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Automatic role upgrade
|
// Automatic role upgrade
|
||||||
|
|||||||
14
app/api/categories/route.ts
Normal file
14
app/api/categories/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const categories = await prisma.businessCategory.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
return NextResponse.json(categories);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch categories' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/api/favorites/route.ts
Normal file
93
app/api/favorites/route.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
// GET /api/favorites - Get user's favorites
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = request.headers.get('x-user-id');
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorites = await prisma.favorite.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
business: {
|
||||||
|
include: {
|
||||||
|
categoryRef: true,
|
||||||
|
country: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(favorites);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/favorites error:', error);
|
||||||
|
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/favorites - Toggle favorite
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = request.headers.get('x-user-id');
|
||||||
|
const body = await request.json();
|
||||||
|
const { businessId } = body;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!businessId) {
|
||||||
|
return NextResponse.json({ error: 'businessId requis' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve business ID (in case slug was passed)
|
||||||
|
const business = await prisma.business.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: businessId },
|
||||||
|
{ slug: businessId }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
return NextResponse.json({ error: 'Entreprise non trouvée' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualBusinessId = business.id;
|
||||||
|
|
||||||
|
// Check if already favorited
|
||||||
|
const existing = await prisma.favorite.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_businessId: {
|
||||||
|
userId,
|
||||||
|
businessId: actualBusinessId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Unfavorite
|
||||||
|
await prisma.favorite.delete({
|
||||||
|
where: { id: existing.id }
|
||||||
|
});
|
||||||
|
return NextResponse.json({ favorited: false });
|
||||||
|
} else {
|
||||||
|
// Favorite
|
||||||
|
await prisma.favorite.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
businessId: actualBusinessId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return NextResponse.json({ favorited: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('POST /api/favorites error:', error);
|
||||||
|
return NextResponse.json({ error: 'Erreur lors du changement de favori' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,12 @@ import prisma from '@/lib/prisma'
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const interviews = await prisma.interview.findMany({
|
const interviews = await prisma.interview.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
})
|
})
|
||||||
return NextResponse.json(interviews)
|
return NextResponse.json(interviews)
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/api/tags/route.ts
Normal file
20
app/api/tags/route.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const blogPosts = await prisma.blogPost.findMany({ select: { tags: true } });
|
||||||
|
const interviews = await prisma.interview.findMany({ select: { tags: true } });
|
||||||
|
const events = await prisma.event.findMany({ select: { tags: true } });
|
||||||
|
const businesses = await prisma.business.findMany({ select: { tags: true } });
|
||||||
|
|
||||||
|
const allTags = new Set<string>();
|
||||||
|
[...blogPosts, ...interviews, ...events, ...businesses].forEach(item => {
|
||||||
|
item.tags.forEach(tag => allTags.add(tag.toLowerCase()));
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(Array.from(allTags).sort());
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch tags' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { isAdmin, USER_SAFE_SELECT } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// GET /api/users
|
// GET /api/users
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
if (!(await isAdmin(request))) {
|
||||||
|
return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
include: { businesses: true },
|
select: {
|
||||||
|
...USER_SAFE_SELECT,
|
||||||
|
businesses: true
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
return NextResponse.json(users)
|
return NextResponse.json(users)
|
||||||
@@ -15,11 +23,22 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/users
|
// POST /api/users (Admin only)
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
if (!(await isAdmin(request))) {
|
||||||
|
return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const user = await prisma.user.create({ data: body })
|
|
||||||
|
// Note: For admins, we might want to allow creating users directly,
|
||||||
|
// but we should still ensure password hashing if provided.
|
||||||
|
// For now, we just restrict it to admins to prevent the public leak.
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: body,
|
||||||
|
select: USER_SAFE_SELECT
|
||||||
|
})
|
||||||
return NextResponse.json(user, { status: 201 })
|
return NextResponse.json(user, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('POST /api/users error:', error)
|
console.error('POST /api/users error:', error)
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { ArrowLeft, User, Calendar, Share2 } from 'lucide-react';
|
|
||||||
import { prisma } from '../../../lib/prisma';
|
|
||||||
|
|
||||||
import { Metadata } from 'next';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const post = await prisma.blogPost.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ id: id },
|
|
||||||
{ slug: id }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!post) return { title: 'Article non trouvé' };
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: post.metaTitle || post.title,
|
|
||||||
description: post.metaDescription || post.excerpt,
|
|
||||||
keywords: post.tags,
|
|
||||||
openGraph: {
|
|
||||||
title: post.metaTitle || post.title,
|
|
||||||
description: post.metaDescription || post.excerpt,
|
|
||||||
images: [post.imageUrl],
|
|
||||||
type: 'article',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogPostPage({ params }: Props) {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const post = await prisma.blogPost.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ id: id },
|
|
||||||
{ slug: id }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!post) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="bg-white min-h-screen">
|
|
||||||
{/* Hero Image */}
|
|
||||||
<div className="w-full h-64 md:h-96 relative">
|
|
||||||
<img
|
|
||||||
src={post.imageUrl}
|
|
||||||
alt={post.title}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
|
||||||
<div className="absolute bottom-0 left-0 w-full p-4 sm:p-8 max-w-4xl mx-auto">
|
|
||||||
<Link href="/blog" className="inline-flex items-center text-white/80 hover:text-white mb-4 text-sm font-medium transition-colors">
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12 -mt-20 relative z-10">
|
|
||||||
<div className="bg-white rounded-xl shadow-xl p-6 md:p-10 border border-gray-100">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="mb-8 border-b border-gray-100 pb-8">
|
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
|
|
||||||
<span className="flex items-center">
|
|
||||||
<User className="w-4 h-4 mr-1 text-brand-500" />
|
|
||||||
{post.author}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
|
|
||||||
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
{post.tags.length > 0 ? (
|
|
||||||
post.tags.map(tag => (
|
|
||||||
<span key={tag} className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
|
||||||
Article
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
|
|
||||||
{post.title}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="prose prose-lg prose-orange max-w-none text-gray-600 break-words overflow-hidden">
|
|
||||||
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
|
|
||||||
{post.excerpt}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className="mt-6"
|
|
||||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer / Share */}
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-100 flex justify-between items-center">
|
|
||||||
<p className="text-sm text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<button className="p-2 rounded-full bg-gray-100 text-gray-600 hover:bg-brand-100 hover:text-brand-600 transition-colors">
|
|
||||||
<Share2 className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getLegalDocument } from '@/lib/legal';
|
import { getLegalDocument } from '@/lib/legal';
|
||||||
|
import { sanitizeHTML } from '@/lib/sanitize';
|
||||||
|
|
||||||
import { getSiteSettings } from '@/lib/settings';
|
import { getSiteSettings } from '@/lib/settings';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -32,7 +33,7 @@ export default async function CGUPage() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="prose prose-orange max-w-none text-gray-600 space-y-6"
|
className="prose prose-orange max-w-none text-gray-600 space-y-6"
|
||||||
dangerouslySetInnerHTML={{ __html: doc.content }}
|
dangerouslySetInnerHTML={{ __html: sanitizeHTML(doc.content) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">
|
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getLegalDocument } from '@/lib/legal';
|
import { getLegalDocument } from '@/lib/legal';
|
||||||
|
import { sanitizeHTML } from '@/lib/sanitize';
|
||||||
|
|
||||||
import { getSiteSettings } from '@/lib/settings';
|
import { getSiteSettings } from '@/lib/settings';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -32,7 +33,7 @@ export default async function CGVPage() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="prose prose-orange max-w-none text-gray-600 space-y-6"
|
className="prose prose-orange max-w-none text-gray-600 space-y-6"
|
||||||
dangerouslySetInnerHTML={{ __html: doc.content }}
|
dangerouslySetInnerHTML={{ __html: sanitizeHTML(doc.content) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">
|
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, Suspense } from 'react';
|
import React, { useState, useEffect, Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye, MessageSquare, User as UserIcon, Rocket, ShieldAlert } from 'lucide-react';
|
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye, MessageSquare, User as UserIcon, Rocket, ShieldAlert, Heart } from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { User } from '../../types';
|
import { User } from '../../types';
|
||||||
import DashboardOverview from '../../components/dashboard/DashboardOverview';
|
import DashboardOverview from '../../components/dashboard/DashboardOverview';
|
||||||
@@ -12,6 +12,8 @@ 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 { useUser } from '../../components/UserProvider';
|
import { useUser } from '../../components/UserProvider';
|
||||||
|
|
||||||
const DashboardContent = () => {
|
const DashboardContent = () => {
|
||||||
@@ -21,7 +23,7 @@ const DashboardContent = () => {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const chatId = searchParams.get('chatId') || searchParams.get('chat');
|
const chatId = searchParams.get('chatId') || searchParams.get('chat');
|
||||||
|
|
||||||
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription' | 'messages' | 'personal-profile'>('overview');
|
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription' | 'messages' | 'personal-profile' | 'favorites'>('overview');
|
||||||
const [business, setBusiness] = useState<any>(null);
|
const [business, setBusiness] = useState<any>(null);
|
||||||
const [stats, setStats] = useState<{ totalViews: number, contactClicks: number, dailyStats: any[] } | null>(null);
|
const [stats, setStats] = useState<{ totalViews: number, contactClicks: number, dailyStats: any[] } | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -33,7 +35,7 @@ const DashboardContent = () => {
|
|||||||
|
|
||||||
// Handle view from URL
|
// Handle view from URL
|
||||||
const viewParam = searchParams.get('view');
|
const viewParam = searchParams.get('view');
|
||||||
if (viewParam && ['overview', 'profile', 'offers', 'subscription', 'messages', 'personal-profile'].includes(viewParam)) {
|
if (viewParam && ['overview', 'profile', 'offers', 'subscription', 'messages', 'personal-profile', 'favorites'].includes(viewParam)) {
|
||||||
setCurrentView(viewParam as any);
|
setCurrentView(viewParam as any);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
@@ -220,6 +222,11 @@ const DashboardContent = () => {
|
|||||||
Mon Profil Client
|
Mon Profil Client
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => setCurrentView('favorites')} className={`${currentView === 'favorites' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
|
||||||
|
<Heart className={`${currentView === 'favorites' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
|
||||||
|
Mes Favoris
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="pt-4 pb-2">
|
<div className="pt-4 pb-2">
|
||||||
<p className="px-3 text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Espace Business</p>
|
<p className="px-3 text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Espace Business</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -267,15 +274,18 @@ const DashboardContent = () => {
|
|||||||
{currentView === 'overview' && 'Vue d\'ensemble'}
|
{currentView === 'overview' && 'Vue d\'ensemble'}
|
||||||
{currentView === 'messages' && 'Mes Messages'}
|
{currentView === 'messages' && 'Mes Messages'}
|
||||||
{currentView === 'personal-profile' && 'Mon Profil Client'}
|
{currentView === 'personal-profile' && 'Mon Profil Client'}
|
||||||
|
{currentView === 'favorites' && 'Mes Favoris'}
|
||||||
{currentView === 'profile' && (user.role === 'ENTREPRENEUR' ? 'Ma Fiche Entreprise' : 'Créer mon Business')}
|
{currentView === 'profile' && (user.role === 'ENTREPRENEUR' ? 'Ma Fiche Entreprise' : 'Créer mon Business')}
|
||||||
{currentView === 'offers' && 'Gestion des Offres'}
|
{currentView === 'offers' && 'Gestion des Offres'}
|
||||||
{currentView === 'subscription' && 'Mon Abonnement'}
|
{currentView === 'subscription' && 'Mon Abonnement'}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<a href={displayBusiness.id === 'new' ? '/annuaire' : `/annuaire/${displayBusiness.slug || displayBusiness.id}`} target="_blank" rel="noopener noreferrer" className="hidden sm:inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
|
{business?.isActive && (
|
||||||
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
|
<Link href={`/annuaire/${business.slug || business.id}`} className="hidden sm:inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none transition-all hover:scale-105 active:scale-95">
|
||||||
Voir ma fiche
|
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
|
||||||
</a>
|
Voir ma fiche
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -302,6 +312,7 @@ const DashboardContent = () => {
|
|||||||
{currentView === 'overview' && <DashboardOverview business={displayBusiness} stats={stats} />}
|
{currentView === 'overview' && <DashboardOverview business={displayBusiness} stats={stats} />}
|
||||||
{currentView === 'messages' && <DashboardMessages onMessagesRead={fetchUnreadCount} initialConversationId={chatId} />}
|
{currentView === 'messages' && <DashboardMessages onMessagesRead={fetchUnreadCount} initialConversationId={chatId} />}
|
||||||
{currentView === 'personal-profile' && <DashboardProfileClient />}
|
{currentView === 'personal-profile' && <DashboardProfileClient />}
|
||||||
|
{currentView === 'favorites' && <DashboardFavorites />}
|
||||||
{currentView === 'profile' && <DashboardProfile business={displayBusiness} setBusiness={setBusiness} />}
|
{currentView === 'profile' && <DashboardProfile business={displayBusiness} setBusiness={setBusiness} />}
|
||||||
{currentView === 'offers' && <DashboardOffers businessId={displayBusiness.id} plan={displayBusiness.plan} />}
|
{currentView === 'offers' && <DashboardOffers businessId={displayBusiness.id} plan={displayBusiness.plan} />}
|
||||||
{currentView === 'subscription' && (
|
{currentView === 'subscription' && (
|
||||||
@@ -316,6 +327,7 @@ const DashboardContent = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<PricingSection />
|
<PricingSection />
|
||||||
|
<OneShotPricingSection />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
113
app/forgot-password/page.tsx
Normal file
113
app/forgot-password/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useUser } from '@/components/UserProvider';
|
||||||
|
|
||||||
|
const ForgotPasswordPage = () => {
|
||||||
|
const { settings } = useUser();
|
||||||
|
const siteName = settings?.siteName || "Afrohub";
|
||||||
|
|
||||||
|
// Fetch CSRF token on mount
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch('/api/auth/csrf')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setCsrfToken(data.csrfToken))
|
||||||
|
.catch(err => console.error('Error fetching CSRF token:', err));
|
||||||
|
}, []);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [csrfToken, setCsrfToken] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-csrf-token': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Une erreur est survenue');
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(data.message);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[80vh] flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-12 w-12 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
|
||||||
|
{siteName.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 font-serif">Mot de passe oublié</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Entrez votre adresse email pour recevoir un lien de réinitialisation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<div className="bg-green-50 border-l-4 border-green-500 p-4 rounded text-green-700 text-sm">
|
||||||
|
{message}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Link href="/login" className="font-medium text-brand-600 hover:text-brand-500"> Retourner à la connexion</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit} method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value={csrfToken} />
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-brand-500 focus:border-brand-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Adresse email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{loading ? 'Envoi...' : 'Envoyer le lien'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<Link href="/login" className="font-medium text-brand-600 hover:text-brand-500 text-sm"> Retourner à la connexion</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForgotPasswordPage;
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Inter', sans-serif;
|
--font-sans: var(--font-inter), sans-serif;
|
||||||
--font-serif: 'Playfair Display', serif;
|
--font-serif: var(--font-playfair), serif;
|
||||||
|
|
||||||
--color-brand-50: #fff7ed;
|
--color-brand-50: #fff7ed;
|
||||||
--color-brand-100: #ffedd5;
|
--color-brand-100: #ffedd5;
|
||||||
@@ -23,4 +23,38 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FORCE ABSOLUE SUR LE CONTENU DU BLOG
|
||||||
|
L'utilisation de l'ID #blog-content garantit que ces règles
|
||||||
|
écrasent les styles par défaut du plugin typography.
|
||||||
|
*/
|
||||||
|
#blog-content,
|
||||||
|
#blog-content *,
|
||||||
|
.prose p,
|
||||||
|
.prose li {
|
||||||
|
word-break: keep-all !important;
|
||||||
|
/* Interdit de couper les mots */
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
/* Autorise le retour à la ligne si le mot est trop long */
|
||||||
|
hyphens: none !important;
|
||||||
|
/* Supprime les tirets de césure */
|
||||||
|
line-break: normal !important;
|
||||||
|
/* Empêche les cassures de caractères bizarres */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nettoyage des titres */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
word-break: normal !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
hyphens: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose p {
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -8,28 +8,58 @@ import CookieBanner from '../components/CookieBanner';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { getSiteSettings } from '../lib/settings';
|
import { getSiteSettings } from '../lib/settings';
|
||||||
|
import { Inter, Playfair_Display } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-inter',
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
|
||||||
|
const playfair = Playfair_Display({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-playfair',
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const settings = await getSiteSettings();
|
const settings = await getSiteSettings();
|
||||||
return {
|
return {
|
||||||
|
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
|
||||||
title: `${settings.siteName} - L'Annuaire 2.0`,
|
title: `${settings.siteName} - L'Annuaire 2.0`,
|
||||||
description: settings.siteSlogan,
|
description: settings.siteSlogan || "Découvrez les pépites de l'écosystème africain.",
|
||||||
|
alternates: {
|
||||||
|
canonical: '/',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const settings = await getSiteSettings();
|
const settings = await getSiteSettings();
|
||||||
|
const isDev = process.env.NEXT_PUBLIC_APP_ENV === 'development';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr" className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="https://cdn-icons-png.flaticon.com/512/1022/1022235.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" />
|
{isDev && (
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: `
|
||||||
|
:root {
|
||||||
|
--color-brand-50: #fef2f2 !important;
|
||||||
|
--color-brand-100: #fee2e2 !important;
|
||||||
|
--color-brand-200: #fecaca !important;
|
||||||
|
--color-brand-500: #ef4444 !important;
|
||||||
|
--color-brand-600: #dc2626 !important;
|
||||||
|
--color-brand-700: #b91c1c !important;
|
||||||
|
--color-brand-900: #7f1d1d !important;
|
||||||
|
}
|
||||||
|
`}} />
|
||||||
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans">
|
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans" suppressHydrationWarning>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
<AnalyticsTracker />
|
<AnalyticsTracker />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user