Compare commits

..

94 Commits

Author SHA1 Message Date
af6ec84cd0 maj metric article 2026-06-21 17:24:31 +02:00
ecfe7fb433 maj api + correction bug 2026-06-08 21:23:39 +02:00
0054f731c7 feat: add JSON import functionality for content creation in the admin dashboard 2026-05-17 13:23:11 +02:00
8f0dc7f38c add DashboardMessages component for real-time chat functionality 2026-05-14 10:09:23 +02:00
ddb37e057d gestion des messages page 404 2026-05-13 21:19:15 +02:00
eb43d65280 correction responsive 2026-05-12 20:32:00 +02:00
bd6380b7aa correction responsive mobile 2026-05-12 20:18:43 +02:00
streap2
b81216210a feat: add home featured toggle, improve account deletion UI, and update business schema and database configuration. 2026-05-12 07:17:04 +02:00
01cf4dd5a1 gestion supp user admin 2026-05-11 22:55:30 +02:00
2cab2ddd1d admin build v3 2026-05-11 17:57:54 +02:00
cfc5f1ac02 correction V2 prisma 2026-05-11 17:45:05 +02:00
139d6991f5 correction npm run build admin 2026-05-11 13:47:27 +02:00
1df2503f0b correction du deploiement pour le front 2026-05-11 09:18:39 +02:00
ea9eaa4b8a monorepo admin supp prisma 2026-05-11 08:37:17 +02:00
3c71e483ac npmrc admin 2026-05-10 22:26:04 +02:00
1b47498f6e feat: add CountryHistogram and AnalyticsMap components and update build script to use legacy-peer-deps 2026-05-10 22:01:03 +02:00
197594f84f feat: implement event submission flow with image upload and modal interface 2026-05-10 21:47:28 +02:00
7db35361d9 fix: update routes type definition path in next-env.d.ts 2026-05-10 18:28:44 +02:00
b6f828357d fix: update Next.js type reference path and exclude scratch directory from TypeScript configuration 2026-05-10 17:52:41 +02:00
9846efd03e feat: implement comprehensive CMS dashboard for managing news, events, interviews, and one-shot pricing plans 2026-05-10 17:50:56 +02:00
87b13dce13 feat: implement manageable homepage banner slider with support for businesses and custom slides 2026-05-10 16:30:19 +02:00
13b1730f6f feat: add related articles section based on tags or most recent posts 2026-05-10 15:15:04 +02:00
5176e51e21 UI: simplify blog footer to a sober and elegant version 2026-05-10 15:13:52 +02:00
f4f303b703 UI: replace share icon footer with a modern CTA section to explore more articles 2026-05-10 15:13:18 +02:00
7174736680 UI: fix sticky positioning for share buttons by removing sticky-killing overflow and ensuring parent stretches 2026-05-10 14:38:42 +02:00
889d3967d2 UI: force vertical sidebar on all devices and ensure it perfectly tracks the article height 2026-05-10 14:37:30 +02:00
94de36e937 UI: make floating share buttons stop at the end of the article 2026-05-10 14:33:38 +02:00
15700036e5 feat: add floating social share buttons and restore text justification 2026-05-10 14:30:38 +02:00
fe7acfa927 UI: aggressive hyphenation disable and left alignment for all text 2026-05-10 13:57:43 +02:00
c6c6a05c47 UI: refine typography, restore justification and prevent word cutting 2026-05-10 13:56:28 +02:00
f64b8ba53c UI: simplify article layout and fix text wrapping 2026-05-10 13:54:50 +02:00
3567e5d224 chore: update next-env.d.ts after successful build 2026-05-10 10:31:28 +02:00
863e8fdc4a Fix: merge cross-platform compatibility adjustments for develop branch 2026-05-10 10:30:37 +02:00
cd862e503d Merge branch 'mac' into develop 2026-05-10 10:29:52 +02:00
343f29459b maj pack sleep package 2026-05-10 09:20:47 +02:00
streap2
4db8271ecf tarif dans footer 2026-05-10 07:19:57 +02:00
streap2
e6d6770c26 modification pour mac 2026-05-10 06:31:58 +02:00
371ed17867 Merge branch 'develop' 2026-05-09 21:35:43 +02:00
f6e3c1bb8c feat: add Twitter support to business profile and implement DashboardProfile component 2026-05-09 21:19:52 +02:00
6acd5ebe2e feat: implement business detail client view with interactive features including rating, favoriting, messaging, and analytics tracking 2026-05-09 21:12:01 +02:00
6a42c52b58 ok 2026-05-09 12:23:29 +02:00
72c24e0cc6 rectification branch develop 2026-05-09 12:23:26 +02:00
67df2d95e9 correction voir ma fiche 2026-05-09 12:13:36 +02:00
4aedcdb45a correction turbo pack 2026-05-09 11:02:48 +02:00
bac36efb94 feat: add Dockerfile for containerized deployment of the Next.js application
Some checks failed
Build and Push App / build (push) Failing after 4m27s
2026-05-06 22:37:47 +02:00
d1b32cd401 feat: add global error boundary component for handling critical application errors 2026-05-06 21:56:52 +02:00
2148f7281e docs: remove obsolete project configuration and documentation files 2026-05-06 21:14:58 +02:00
7b015cb758 hul correctuib 2026-05-06 21:00:15 +02:00
bb464cf1c5 feat: implement DynamicProviders to disable SSR for global context providers in root layout 2026-05-06 20:47:29 +02:00
19b405d9f2 feat: implement RootLayout with dynamic provider loading and metadata generation 2026-05-06 20:37:39 +02:00
a37b34f758 feat: initialize root layout with dynamic provider rendering and site metadata configuration 2026-05-06 20:28:21 +02:00
13c88dd4f7 ajout provider pour rendre le layout dynamique 2026-05-06 20:19:09 +02:00
52a9d47c30 correction global error détachement du layout 2026-05-06 19:53:36 +02:00
f93d70c447 suppression du run build admin pour le build normal 2026-05-06 17:29:16 +02:00
c73763be8a suppression build .next erreur global-error 2026-05-06 17:22:36 +02:00
268e14f369 modification du global error 2026-05-06 09:03:06 +02:00
e1aa0f076a restauration fichier docker 2026-05-06 08:19:46 +02:00
3d7cb5f4d1 maj plantage turbopack 2026-05-05 23:13:31 +02:00
2d9d707251 correction build error 2026-05-04 21:40:18 +02:00
5720661f8d maj banniere prod/dev 2026-05-04 21:30:10 +02:00
1b6d409319 maj color develop 2026-05-04 21:18:41 +02:00
75ae9c5f3d pré supp dockerfile 2026-05-04 20:49:21 +02:00
63b5af4868 suppression des fichiers inutile 2026-05-04 20:47:35 +02:00
a23c3bf780 maj bdd instruction 2026-05-04 20:34:13 +02:00
16d66c0456 migration correction et maj
Some checks failed
Build and Push App / build (push) Failing after 11m56s
2026-05-03 23:15:14 +02:00
5f421b418e Maj taxonomies
Some checks failed
Build and Push App / build (push) Failing after 11m28s
maj url photo
+ page 404
+ gestion programmation d'article
2026-05-03 21:22:12 +02:00
0e72867d4c correction deploy
All checks were successful
Build and Push App / build (push) Successful in 5m57s
2026-05-03 16:21:12 +02:00
2122075801 maj package
All checks were successful
Build and Push App / build (push) Successful in 6m49s
2026-05-03 13:22:40 +02:00
0f526c851a correction package json
All checks were successful
Build and Push App / build (push) Successful in 6m16s
2026-05-03 12:44:13 +02:00
79a9cbffe9 correction deploiement
Some checks failed
Build and Push App / build (push) Failing after 7s
2026-05-02 22:05:26 +02:00
f450f8ee03 correction faille de securité
All checks were successful
Build and Push App / build (push) Successful in 5m50s
2026-05-01 21:48:00 +02:00
9c003d1b7d feat: implement full authentication system with CSRF protection and email verification flow
All checks were successful
Build and Push App / build (push) Successful in 5m59s
2026-04-30 23:46:34 +02:00
4e881bcbe6 feat: implement business and user CRUD API endpoints with authentication utilities and update feature documentation
All checks were successful
Build and Push App / build (push) Successful in 5m46s
2026-04-30 23:05:04 +02:00
81b068e5c9 feat: initialize Prisma database with migration and seed scripts for blog and afrolife data
Some checks failed
Build and Push App / build (push) Failing after 11m14s
2026-04-28 16:03:37 +02:00
b55b15a2aa correction build
Some checks failed
Build and Push App / build (push) Failing after 22s
2026-04-27 23:32:11 +02:00
159f1c1709 maj npx prisma migrate au lieu de generate
Some checks failed
Build and Push App / build (push) Failing after 40s
2026-04-27 23:18:54 +02:00
a814c7b577 ajout de la gestion des favoris
All checks were successful
Build and Push App / build (push) Successful in 13m38s
2026-04-27 21:44:48 +02:00
38caf36bee correction redirection verification mail
All checks were successful
Build and Push App / build (push) Successful in 11m14s
2026-04-27 08:47:05 +02:00
streap2
ef7ac122f8 feat: add email verification resend functionality to login page and backend route
All checks were successful
Build and Push App / build (push) Successful in 5m35s
2026-04-27 08:01:51 +02:00
streap2
7900c9d5be fix: debug SMTP configuration and disable TLS certificate verification, and update Next.js route type path
All checks were successful
Build and Push App / build (push) Successful in 6m2s
2026-04-27 07:39:57 +02:00
b89dc62408 fix: add --accept-data-loss flag to prisma db push in start script
All checks were successful
Build and Push App / build (push) Successful in 7m45s
2026-04-26 23:33:23 +02:00
792a951e02 feat: initialize Prisma schema with core business, user, and communication models and configure docker-compose for database deployment
All checks were successful
Build and Push App / build (push) Successful in 8m16s
2026-04-26 23:10:05 +02:00
27b35104b9 feat: initialize Prisma schema with models for users, businesses, content, and site settings
Some checks failed
Build and Push App / build (push) Has been cancelled
2026-04-26 23:01:45 +02:00
d932186727 feat: initialize admin package with Next.js 16 and Prisma dependencies and update type declaration route path
Some checks failed
Build and Push App / build (push) Failing after 1m10s
2026-04-26 22:51:04 +02:00
281fb764c4 feat: implement authentication system with email verification, user management dashboard, and administrative business controls
Some checks failed
Build and Push App / build (push) Failing after 1m9s
2026-04-26 22:46:32 +02:00
streap2
31fa2fcde3 Maj smtp, reset password, et ajout afroshine
All checks were successful
Build and Push App / build (push) Successful in 10m29s
2026-04-26 14:32:38 +02:00
streap2
a551fe6bcc chore: force dynamic rendering for sitemap generation
Some checks failed
Build and Push App / build (push) Failing after 9m35s
2026-04-26 08:48:01 +02:00
streap2
668b6bd8b8 chore: downgrade Prisma to v6.19.3, remove redundant config files, and update schema datasource configurations
All checks were successful
Build and Push App / build (push) Successful in 17m49s
2026-04-26 08:21:36 +02:00
streap2
d1e551bcca feat: implement SEO enhancements, automatic slug generation, and font optimizations
All checks were successful
Build and Push App / build (push) Successful in 9m12s
2026-04-26 08:11:31 +02:00
3c58e60b33 feat: add Event interface, implement core Prisma seeding, and create admin categories management page
All checks were successful
Build and Push App / build (push) Successful in 11m4s
2026-04-24 14:39:01 +02:00
11678e0b33 feat: add seeding script for pricing plans in database 2026-04-23 15:00:49 +02:00
1a66c46389 feat: add admin interface to manage and edit pricing plans
Some checks failed
Build and Push App / build (push) Failing after 46s
2026-04-23 14:50:33 +02:00
e6310f30de feat: implement home page, business directory, and administrative management features with updated schema and API routes
Some checks failed
Build and Push App / build (push) Failing after 51s
2026-04-23 14:40:50 +02:00
88e4c13b9f feat: implement dynamic home page with configurable blog and category settings 2026-04-23 13:41:51 +02:00
188 changed files with 19311 additions and 12797 deletions

5
.env
View File

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

View File

@@ -1,28 +0,0 @@
name: Build and Push App
on:
push:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
# FORCE l'image ici pour être sûr d'avoir l'outil Docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Login to Gitea Registry
run: |
echo "${{ secrets.TOKEN }}" | docker login git.home.arrondeau.fr -u ${{ gitea.actor }} --password-stdin
- name: Build and Push Docker Images
run: |
# Build and Push FRONT
docker build -t git.home.arrondeau.fr/${{ gitea.repository }}:latest .
docker push git.home.arrondeau.fr/${{ gitea.repository }}:latest
# Build and Push ADMIN
docker build -t git.home.arrondeau.fr/${{ gitea.repository }}-admin:latest ./admin
docker push git.home.arrondeau.fr/${{ gitea.repository }}-admin:latest

1
admin/.npmrc Normal file
View File

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

View File

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

7949
admin/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,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,
},
};

View File

@@ -1,314 +0,0 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(uuid())
name String
email String @unique
password String
role UserRole @default(VISITOR)
avatar String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bio String?
location String?
countryId String?
phone String?
isSuspended Boolean @default(false)
suspensionReason String?
businesses Business?
comments Comment[]
conversations ConversationParticipant[]
sentMessages Message[]
reports MessageReport[]
ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
}
model Business {
id String @id @default(uuid())
name String
category String
location String // Legacy location string
countryId String?
city String?
description String
logoUrl String
videoUrl String?
contactEmail String
contactPhone String?
socialLinks Json?
verified Boolean @default(false)
viewCount Int @default(0)
rating Float @default(0.0)
ratingCount Int @default(0)
tags String[]
isFeatured Boolean @default(false)
founderName String?
founderImageUrl String?
keyMetric String?
ownerId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slug String? @unique
isActive Boolean @default(false)
showEmail Boolean @default(true)
showPhone Boolean @default(true)
showSocials Boolean @default(true)
websiteUrl String?
isSuspended Boolean @default(false)
suspensionReason String?
plan Plan @default(STARTER)
owner User @relation(fields: [ownerId], references: [id])
comments Comment[]
conversations Conversation[]
offers Offer[]
ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
}
model Country {
id String @id @default(uuid())
name String @unique
code String @unique // ISO alpha-2
flag String? // Emoji or URL
isActive Boolean @default(true)
businesses Business[]
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum Plan {
STARTER
BOOSTER
EMPIRE
}
model PricingPlan {
id String @id @default(uuid())
tier Plan @unique
name String
priceXOF String
priceEUR String
description String
features String[]
offerLimit Int @default(1)
recommended Boolean @default(false)
color String @default("gray")
updatedAt DateTime @updatedAt
}
model Offer {
id String @id @default(uuid())
title String
type OfferType
price Float
currency String @default("XOF")
description String?
imageUrl String
active Boolean @default(true)
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
}
model BlogPost {
id String @id @default(uuid())
title String
slug String? @unique
excerpt String
content String
author String
date DateTime @default(now())
imageUrl String
tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Interview {
id String @id @default(uuid())
title String
slug String? @unique
guestName String
companyName String
role String
type InterviewType
thumbnailUrl String
videoUrl String?
content String?
excerpt String
date DateTime @default(now())
duration String?
tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id String @id @default(uuid())
content String
authorId String
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
}
model AnalyticsEvent {
id String @id @default(uuid())
type String
path String
label String?
value Float?
metadata Json?
ip String?
country String?
city String?
browser String?
os String?
device String?
referrer String?
userId String?
createdAt DateTime @default(now())
}
model Rating {
id String @id @default(uuid())
value Int
comment String?
reply String?
replyAt DateTime?
status RatingStatus @default(PENDING)
userId String
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
@@unique([userId, businessId])
}
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
model Conversation {
id String @id @default(uuid())
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
participants ConversationParticipant[]
messages Message[]
}
model ConversationParticipant {
id String @id @default(uuid())
userId String
conversationId String
isArchived Boolean @default(false)
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, conversationId])
}
model Message {
id String @id @default(uuid())
content String
senderId String
conversationId String
createdAt DateTime @default(now())
read Boolean @default(false)
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
reports MessageReport[]
}
model MessageReport {
id String @id @default(uuid())
reason String?
messageId String
reporterId String
status ReportStatus @default(PENDING)
createdAt DateTime @default(now())
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
}
enum ReportStatus {
PENDING
RESOLVED
DISMISSED
}
enum UserRole {
VISITOR
ENTREPRENEUR
ADMIN
}
enum OfferType {
PRODUCT
SERVICE
}
enum InterviewType {
VIDEO
ARTICLE
}
model SiteSetting {
id String @id @default("singleton")
siteName String @default("Afrohub")
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
contactEmail String @default("support@afrohub.com")
contactPhone String? @default("+225 00 00 00 00 00")
address String? @default("Abidjan, Côte d'Ivoire")
facebookUrl String?
twitterUrl String?
instagramUrl String?
linkedinUrl String?
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
updatedAt DateTime @updatedAt
}
model LegalDocument {
id String @id @default(uuid())
type String @unique // 'CGU' or 'CGV'
title String
content String @db.Text
updatedAt DateTime @updatedAt
}
model Event {
id String @id @default(uuid())
title String
slug String? @unique
description String @db.Text
date DateTime
location String
thumbnailUrl String
link String?
tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -3,6 +3,7 @@
import { prisma } from "@/lib/prisma"; import { 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,11 @@ export async function createBlogPost(data: {
tags?: string[]; tags?: string[];
metaTitle?: string; metaTitle?: string;
metaDescription?: string; metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
sources?: any;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);
@@ -23,10 +29,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 +52,11 @@ export async function updateBlogPost(id: string, data: {
tags?: string[]; tags?: string[];
metaTitle?: string; metaTitle?: string;
metaDescription?: string; metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
coverPosition?: string;
coverZoom?: number;
sources?: any;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);
@@ -55,8 +69,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 +83,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 [];
}
}

View File

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

View File

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

View 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 [];
}
}

View File

@@ -14,13 +14,14 @@ export async function getCountries() {
} }
} }
export async function addCountry(data: { name: string, code: string, flag?: string }) { export async function addCountry(data: { name: string, code: string, flag?: string, description?: string }) {
try { 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.' };
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,40 @@ 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é"],
notFoundQuotes: ["L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."],
resetPasswordSubject: "Réinitialisation de votre mot de passe - Afrohub",
resetPasswordTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
<p>Bonjour {name},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
</div>
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 {siteName}. Tous droits réservés.</p>
</div>`,
verifyEmailSubject: "Vérification de votre compte - Afrohub",
verifyEmailTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
<p>Bonjour {name},</p>
<p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
</div>
<p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p>
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 {siteName}. Tous droits réservés.</p>
</div>`
}; };
if (!settings) return defaultSettings; if (!settings) return defaultSettings;
@@ -43,7 +76,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) {
@@ -51,3 +84,7 @@ export async function updateSiteSettings(data: any) {
return { success: false, error: "Erreur lors de la mise à jour des paramètres" }; return { success: false, error: "Erreur lors de la mise à jour des paramètres" };
} }
} }
export async function getExternalApiKey() {
return process.env.EXTERNAL_API_KEY || "ezfzeFZEfztZEgZEFzETGZEGZERZERF";
}

View File

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

View File

@@ -0,0 +1,138 @@
'use server';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getAllTags() {
try {
const [blogPosts, interviews, events] = await Promise.all([
prisma.blogPost.findMany({ select: { tags: true } }),
prisma.interview.findMany({ select: { tags: true } }),
prisma.event.findMany({ select: { tags: true } }),
]);
const allTags = new Set<string>();
blogPosts.forEach(p => p.tags.forEach(t => allTags.add(t)));
interviews.forEach(i => i.tags.forEach(t => allTags.add(t)));
events.forEach(e => e.tags.forEach(t => allTags.add(t)));
return Array.from(allTags).sort();
} catch (error) {
console.error('Error fetching tags:', error);
return [];
}
}
export async function deleteTagGlobally(tagName: string) {
try {
// This is expensive as we need to update all records
// In a real app, a Tag model would be better
const [blogPosts, interviews, events] = await Promise.all([
prisma.blogPost.findMany({ where: { tags: { has: tagName } } }),
prisma.interview.findMany({ where: { tags: { has: tagName } } }),
prisma.event.findMany({ where: { tags: { has: tagName } } }),
]);
await Promise.all([
...blogPosts.map(p => prisma.blogPost.update({
where: { id: p.id },
data: { tags: { set: p.tags.filter(t => t !== tagName) } }
})),
...interviews.map(i => prisma.interview.update({
where: { id: i.id },
data: { tags: { set: i.tags.filter(t => t !== tagName) } }
})),
...events.map(e => prisma.event.update({
where: { id: e.id },
data: { tags: { set: e.tags.filter(t => t !== tagName) } }
}))
]);
revalidatePath('/taxonomies');
return { success: true };
} catch (error) {
console.error('Error deleting tag globally:', error);
return { success: false, error: 'Erreur lors de la suppression du tag' };
}
}
export async function renameTagGlobally(oldName: string, newName: string) {
try {
const cleanNewName = newName.trim().toLowerCase();
if (!cleanNewName) return { success: false, error: 'Nouveau nom invalide' };
const [blogPosts, interviews, events] = await Promise.all([
prisma.blogPost.findMany({ where: { tags: { has: oldName } } }),
prisma.interview.findMany({ where: { tags: { has: oldName } } }),
prisma.event.findMany({ where: { tags: { has: oldName } } }),
]);
await Promise.all([
...blogPosts.map(p => prisma.blogPost.update({
where: { id: p.id },
data: { tags: { set: p.tags.map(t => t === oldName ? cleanNewName : t) } }
})),
...interviews.map(i => prisma.interview.update({
where: { id: i.id },
data: { tags: { set: i.tags.map(t => t === oldName ? cleanNewName : t) } }
})),
...events.map(e => prisma.event.update({
where: { id: e.id },
data: { tags: { set: e.tags.map(t => t === oldName ? cleanNewName : t) } }
}))
]);
revalidatePath('/taxonomies');
return { success: true };
} catch (error) {
console.error('Error renaming tag globally:', error);
return { success: false, error: 'Erreur lors du renommage du tag' };
}
}
export async function getTagUsage(tagName: string) {
try {
const [blogPosts, interviews, events] = await Promise.all([
prisma.blogPost.findMany({
where: { tags: { has: tagName } },
select: { id: true, title: true, status: true, author: true }
}),
prisma.interview.findMany({
where: { tags: { has: tagName } },
select: { id: true, title: true, status: true, guestName: true }
}),
prisma.event.findMany({
where: { tags: { has: tagName } },
select: { id: true, title: true, status: true, location: true }
}),
]);
return {
success: true,
data: {
blogPosts: blogPosts.map(p => ({
id: p.id,
title: p.title,
status: p.status,
type: 'blog'
})),
interviews: interviews.map(i => ({
id: i.id,
title: i.title,
status: i.status,
type: 'interview'
})),
events: events.map(e => ({
id: e.id,
title: e.title,
status: e.status,
type: 'event'
})),
}
};
} catch (error) {
console.error('Error fetching tag usage:', error);
return { success: false, error: 'Erreur lors de la récupération de l\'usage' };
}
}

View File

@@ -0,0 +1,44 @@
'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
let fileExtension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
if (fileExtension === 'jpeg') fileExtension = 'jpg';
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
// Path for the ROOT public/uploads (where the main site will look)
const rootPublicDir = join(process.cwd(), '..', 'public', 'uploads');
// Path for the ADMIN public/uploads (where the admin app can see it for preview)
const adminPublicDir = join(process.cwd(), 'public', 'uploads');
// Ensure both directories exist
await mkdir(rootPublicDir, { recursive: true });
await mkdir(adminPublicDir, { recursive: true });
// Save to both
await writeFile(join(rootPublicDir, fileName), buffer);
await writeFile(join(adminPublicDir, fileName), buffer);
// Return the relative URL
return {
success: true,
url: `/uploads/${fileName}`
};
} catch (error) {
console.error('Error during image upload:', error);
return { success: false, error: 'Erreur lors de l\'upload de l\'image' };
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import Link from 'next/link'; import Link from 'next/link';
import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin } from 'lucide-react'; import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin, Clock, ShieldCheck, ShieldAlert } from 'lucide-react';
import DeleteBlogButton from '@/components/DeleteBlogButton'; import DeleteActualitesButton from '@/components/DeleteActualitesButton';
import DeleteInterviewButton from '@/components/DeleteInterviewButton'; import DeleteInterviewButton from '@/components/DeleteInterviewButton';
import DeleteEventButton from '@/components/DeleteEventButton'; import DeleteEventButton from '@/components/DeleteEventButton';
import ApproveContentButton from '@/components/ApproveContentButton';
import { FileJson } from 'lucide-react';
async function getData() { async function getData() {
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } }); const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
@@ -12,18 +14,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>
@@ -35,21 +51,88 @@ export default async function BlogCMSPage() {
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Nouvel Événement Nouvel Événement
</Link> </Link>
</div> </div>
</div> </div>
{/* PENDING SUBMISSIONS SECTION */}
{hasPending && (
<div className="bg-amber-500/5 border border-amber-500/20 rounded-2xl p-6 mb-8 animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-amber-500/20 rounded-xl text-amber-500">
<ShieldAlert className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Soumissions en attente ({pendingPosts.length + pendingEvents.length})</h2>
<p className="text-sm text-slate-400">Contenu soumis par les utilisateurs nécessitant une validation.</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4">
{pendingPosts.map(post => (
<div key={post.id} className="bg-slate-900/50 border border-slate-800 rounded-xl p-4 flex items-center justify-between group hover:border-amber-500/30 transition-all">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-indigo-500/10 rounded-lg flex items-center justify-center text-indigo-400">
<BookOpen className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-white">{post.title}</h4>
<span className="text-[10px] bg-indigo-500/10 text-indigo-400 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter border border-indigo-500/20">Article</span>
</div>
<p className="text-xs text-slate-500 mt-0.5">Par {post.author} {new Date(post.createdAt).toLocaleDateString('fr-FR')}</p>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Link href={`/actualites/edit/${post.id}`} className="p-2 text-slate-400 hover:text-white" title="Modifier/Voir">
<Edit className="w-5 h-5" />
</Link>
<ApproveContentButton id={post.id} type="post" action="approve" />
<ApproveContentButton id={post.id} type="post" action="reject" />
</div>
</div>
))}
{pendingEvents.map(event => (
<div key={event.id} className="bg-slate-900/50 border border-slate-800 rounded-xl p-4 flex items-center justify-between group hover:border-amber-500/30 transition-all">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-amber-500/10 rounded-lg flex items-center justify-center text-amber-500">
<Calendar className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-white">{event.title}</h4>
<span className="text-[10px] bg-amber-500/10 text-amber-400 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter border border-amber-500/20">Événement</span>
</div>
<p className="text-xs text-slate-500 mt-0.5">{new Date(event.date).toLocaleDateString('fr-FR')} {event.location}</p>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Link href={`/event/edit/${event.id}`} className="p-2 text-slate-400 hover:text-white" title="Modifier/Voir">
<Edit className="w-5 h-5" />
</Link>
<ApproveContentButton id={event.id} type="event" action="approve" />
<ApproveContentButton id={event.id} type="event" action="reject" />
</div>
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 gap-8"> <div className="grid grid-cols-1 gap-8">
{/* Blog Posts Section */} {/* Blog Posts Section */}
<div className="card"> <div className="card">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<BookOpen className="text-indigo-400 w-5 h-5" /> <BookOpen className="text-indigo-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Articles de Blog ({posts.length})</h2> <h2 className="text-xl font-bold text-white">Articles d'Actualités ({posts.length})</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="data-table w-full"> <table className="data-table w-full">
<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 +145,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 +174,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 +187,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 +223,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 +237,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" />

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,110 +0,0 @@
import { getClients } from "@/app/actions/user";
import { User, Mail, Phone, MapPin } from "lucide-react";
import ClientActions from "@/components/ClientActions";
export default async function ClientsPage() {
const result = await getClients();
const clients = result.success ? (result.data || []) : [];
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Clients</h1>
<p className="text-slate-400">Liste des utilisateurs n'ayant pas encore de boutique active.</p>
</div>
<div className="bg-slate-800/50 px-4 py-2 rounded-lg border border-slate-700/50">
<span className="text-slate-400 text-sm">Total Clients: </span>
<span className="text-indigo-400 font-bold ml-1">{clients.length}</span>
</div>
</div>
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Utilisateur</th>
<th>Contact</th>
<th>Localisation</th>
<th>Statut</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{clients.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-500">
<div className="flex flex-col items-center gap-2">
<User className="w-8 h-8 opacity-20" />
<p>Aucun client trouvé.</p>
</div>
</td>
</tr>
) : (
clients.map((client: any) => (
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
<td className="py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
{client.avatar ? (
<img src={client.avatar} alt={client.name} className="w-full h-full object-cover" />
) : (
client.name.charAt(0)
)}
</div>
<div>
<p className="text-white font-semibold">{client.name}</p>
<p className="text-slate-500 text-xs">ID: {client.id.split('-')[0]}...</p>
</div>
</div>
</td>
<td>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Mail className="w-3 h-3 text-slate-500" />
{client.email}
</div>
{client.phone && (
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Phone className="w-3 h-3 text-slate-500" />
{client.phone}
</div>
)}
</div>
</td>
<td>
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<MapPin className="w-3 h-3" />
{client.location || "Non spécifié"}
</div>
</td>
<td>
<div className="flex flex-col gap-1">
{client.isSuspended ? (
<>
<span className="badge badge-error">Compte Suspendu</span>
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
{client.suspensionReason}
</span>
</>
) : client.businesses ? (
<span className="badge badge-pending">Shop Inactif</span>
) : (
<span className="badge bg-slate-800 text-slate-400">Actif</span>
)}
</div>
</td>
<td className="text-right">
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,14 +1,16 @@
"use client"; "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">

View File

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

View File

@@ -1,13 +1,7 @@
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 getDashboardData() {
const usersCount = await prisma.user.count(); const usersCount = await prisma.user.count();
const businessCount = await prisma.business.count(); const businessCount = await prisma.business.count();
const pendingCount = await prisma.business.count({ where: { verified: false } }); const pendingCount = await prisma.business.count({ where: { verified: false } });
@@ -27,56 +21,81 @@ async function getStats() {
}); });
const uniqueVisitors = uniqueVisitorsRes.length; const uniqueVisitors = uniqueVisitorsRes.length;
return { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors }; // Derniers entrepreneurs
const latestBusinesses = await prisma.business.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
category: true,
logoUrl: true,
location: true,
createdAt: true,
verified: true,
plan: true,
owner: {
select: {
name: true,
email: true,
}
}
}
});
// Activité récente (combiner commentaires et nouveaux utilisateurs)
const recentComments = await prisma.comment.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
content: true,
createdAt: true,
author: { select: { name: true, avatar: true } },
business: { select: { name: true } }
}
});
const recentUsers = await prisma.user.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
role: true,
avatar: true,
createdAt: true,
}
});
// Mapper et trier
const activities = [
...recentComments.map(c => ({
id: `comment-${c.id}`,
type: 'comment' as const,
title: `${c.author?.name || 'Un utilisateur'} a commenté sur ${c.business?.name || 'une entreprise'}`,
subtitle: c.content.length > 60 ? c.content.substring(0, 60) + '...' : c.content,
timestamp: c.createdAt,
avatar: c.author?.avatar
})),
...recentUsers.map(u => ({
id: `user-${u.id}`,
type: 'user' as const,
title: `Nouvel utilisateur inscrit`,
subtitle: `${u.name} a rejoint la plateforme en tant que ${u.role === 'ENTREPRENEUR' ? 'Entrepreneur' : 'Visiteur'}`,
timestamp: u.createdAt,
avatar: u.avatar
}))
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()).slice(0, 6);
return {
stats: { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors },
latestBusinesses,
activities
};
} }
export default async function DashboardPage() { export default async function DashboardPage() {
const stats = await getStats(); const data = await getDashboardData();
return <DashboardClient initialData={data} />;
const statCards = [
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
];
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
</div>
<div className="stats-grid">
{statCards.map((card) => (
<div key={card.label} className="card">
<div className="flex justify-between items-start mb-4">
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
<card.icon className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">{card.value}</span>
</div>
<h3 className="text-slate-400 font-medium">{card.label}</h3>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
</div>
</div>
);
} }

View File

@@ -1,145 +0,0 @@
import { prisma } from '@/lib/prisma';
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
import FeaturedModal from '@/components/FeaturedModal';
import EntrepreneurActions from '@/components/EntrepreneurActions';
import PlanSelector from '@/components/PlanSelector';
import { ShieldAlert, ShieldX, ShieldCheck } from 'lucide-react';
async function getBusinesses() {
return await prisma.business.findMany({
where: {
isActive: true
},
include: {
owner: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
export default async function EntrepreneursPage() {
const businesses = await getBusinesses();
const pendingCount = businesses.filter((b: any) =>
!b.verified &&
!b.isSuspended &&
['BOOSTER', 'EMPIRE'].includes(b.plan)
).length;
return (
<div>
<div className="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Entrepreneurs</h1>
<p className="text-slate-400">Gérer les abonnements et certifier les comptes (Badge Bleu).</p>
</div>
{pendingCount > 0 && (
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-3 rounded-2xl flex items-center gap-4 animate-pulse">
<div className="w-10 h-10 bg-blue-500 rounded-xl flex items-center justify-center text-white">
<ShieldCheck className="w-6 h-6" />
</div>
<div>
<div className="text-blue-400 font-bold">{pendingCount} en attente</div>
<div className="text-xs text-blue-400/80 font-medium uppercase tracking-wider">Certifications Booster</div>
</div>
</div>
)}
</div>
<div className="card overflow-hidden p-0">
<table className="data-table">
<thead>
<tr>
<th>Entreprise</th>
<th>Catégorie</th>
<th>Propriétaire</th>
<th>Statut</th>
<th className="text-center">Forfait</th>
<th className="text-center">Visibilité</th>
<th className="text-center">Mise en avant</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody>
{businesses.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-10 text-slate-500">
Aucun entrepreneur trouvé.
</td>
</tr>
) : (
businesses.map((business: any) => (
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
<td>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
{business.logoUrl ? (
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
) : (
<span className="text-xs text-slate-500">LOGO</span>
)}
</div>
<div>
<div className="font-semibold text-white">{business.name}</div>
<div className="text-xs text-slate-500">{business.location}</div>
</div>
</div>
</td>
<td>
<span className="text-sm text-slate-300">{business.category}</span>
</td>
<td>
<div className="text-sm text-white">{business.owner.name}</div>
<div className="text-xs text-slate-500">{business.owner.email}</div>
</td>
<td>
<div className="flex flex-col gap-1">
{business.owner?.isSuspended ? (
<span className="badge badge-error flex items-center gap-1">
<ShieldX className="w-3 h-3" /> Compte Suspendu
</span>
) : business.isSuspended ? (
<span className="badge bg-orange-500/10 text-orange-500 border border-orange-500/20 flex items-center gap-1">
<ShieldAlert className="w-3 h-3" /> Shop Masqué
</span>
) : business.verified ? (
<span className="badge badge-verified">Vérifié</span>
) : (
<span className="badge badge-pending">En attente</span>
)}
</div>
</td>
<td className="text-center">
<PlanSelector businessId={business.id} currentPlan={business.plan} />
</td>
<td className="text-center font-medium text-slate-300">
{business.viewCount.toLocaleString()}
</td>
<td className="text-center">
<FeaturedModal business={business} />
</td>
<td className="text-right">
<div className="flex items-center justify-end gap-3">
<ToggleVerifyButton id={business.id} verified={business.verified} />
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
<EntrepreneurActions
businessId={business.id}
businessName={business.name}
userId={business.owner.id}
userName={business.owner.name}
isBusinessSuspended={!!business.isSuspended}
isUserSuspended={!!business.owner?.isSuspended}
/>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
"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-4">
<div className="max-w-md w-full text-center space-y-8">
<div className="relative">
<h1 className="text-[150px] font-black text-slate-900 leading-none select-none">404</h1>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-4xl font-bold text-white tracking-widest uppercase">Perdu ?</span>
</div>
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold text-white">Cette page n'existe pas</h2>
<p className="text-slate-400">Le contenu que vous recherchez semble avoir é déplacé ou supprimé.</p>
</div>
<div className="space-y-3">
<Link
href="/dashboard"
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition-colors"
>
<LayoutDashboard className="w-5 h-5" />
Retour au Dashboard
</Link>
<button
onClick={() => window.history.back()}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-slate-900 text-slate-300 border border-slate-800 rounded-xl font-semibold hover:bg-slate-800 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
Page précédente
</button>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -2,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>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,115 @@
"use server";
import { prisma } from "@/lib/prisma";
import Link from 'next/link';
import { Plus, Image as ImageIcon, Trash2, Edit, ExternalLink, Power, PowerOff } from 'lucide-react';
import DeleteSlideButton from '@/components/DeleteSlideButton';
import ToggleSlideStatusButton from '@/components/ToggleSlideStatusButton';
async function getData() {
try {
return await prisma.homeSlide.findMany({
orderBy: { order: 'asc' },
include: { business: true }
});
} catch (error) {
console.error("Failed to fetch slides:", error);
return [];
}
}
export default async function SlidesPage() {
const slides = await getData();
return (
<div className="space-y-8">
<div className="flex justify-between items-end mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion de la Bannière</h1>
<p className="text-slate-400">Gérez les slides qui s'affichent sur la page d'accueil (Slider).</p>
</div>
<Link href="/slides/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
<Plus className="w-4 h-4" />
Nouvelle Slide
</Link>
</div>
<div className="card">
<div className="flex items-center gap-2 mb-6">
<ImageIcon className="text-indigo-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Slides Actuelles ({slides.length})</h2>
</div>
{slides.length === 0 ? (
<div className="text-center py-12 bg-slate-800/20 rounded-xl border border-dashed border-slate-700">
<p className="text-slate-500">Aucune slide configurée. Le bandeau par défaut sera affiché.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="data-table w-full">
<thead>
<tr>
<th className="text-left py-3 px-4">Ordre</th>
<th className="text-left py-3 px-4">Type</th>
<th className="text-left py-3 px-4">Contenu</th>
<th className="text-left py-3 px-4">Statut</th>
<th className="text-right py-3 px-4">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{slides.map((slide) => (
<tr key={slide.id} className="hover:bg-slate-800/30 transition-colors">
<td className="py-4 px-4">
<span className="text-white font-mono bg-slate-800 px-2 py-1 rounded">#{slide.order}</span>
</td>
<td className="py-4 px-4">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${slide.type === 'BUSINESS' ? 'bg-indigo-500/10 text-indigo-400 border border-indigo-500/20' : 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'}`}>
{slide.type === 'BUSINESS' ? 'ENTREPRISE' : 'PERSONNALISÉ'}
</span>
</td>
<td className="py-4 px-4">
<div className="flex items-center gap-3">
{slide.type === 'BUSINESS' ? (
<>
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
<img src={slide.business?.logoUrl || '/placeholder-biz.png'} className="w-full h-full object-cover" alt="" />
</div>
<div>
<div className="text-sm font-bold text-white">{slide.business?.name}</div>
<div className="text-xs text-slate-500">{slide.business?.location}</div>
</div>
</>
) : (
<>
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
<img src={slide.imageUrl || '/placeholder-img.png'} className="w-full h-full object-cover" alt="" />
</div>
<div>
<div className="text-sm font-bold text-white">{slide.title || 'Sans titre'}</div>
<div className="text-xs text-slate-500 truncate max-w-[250px]">{slide.linkUrl || 'Pas de lien'}</div>
</div>
</>
)}
</div>
</td>
<td className="py-4 px-4">
<ToggleSlideStatusButton id={slide.id} currentStatus={slide.isActive} />
</td>
<td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2">
<Link href={`/slides/edit/${slide.id}`} className="p-2 text-slate-400 hover:text-indigo-400 transition-colors">
<Edit className="w-5 h-5" />
</Link>
<DeleteSlideButton id={slide.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,415 @@
"use client";
import React, { useState, useEffect, useTransition } from 'react';
import {
Plus,
Edit2,
Trash2,
Check,
X,
Loader2,
Briefcase,
AlertCircle,
Clock,
Sparkles,
Cpu,
Sprout,
Shirt,
Utensils,
HardHat,
Stethoscope,
GraduationCap,
Palette,
Plane,
Truck,
Wallet,
Zap,
Leaf,
Camera,
Music,
ShoppingBag,
Heart,
Home,
Hash,
Tags,
Search,
ExternalLink,
Eye,
FileText,
User as UserIcon,
MapPin,
BookOpen,
Save
} from 'lucide-react';
import Link from 'next/link';
import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
import { getAllTags, deleteTagGlobally, renameTagGlobally, getTagUsage } from '@/app/actions/taxonomies';
import { toast } from 'react-hot-toast';
const ICON_LIST = [
{ name: 'Briefcase', icon: Briefcase },
{ name: 'Cpu', icon: Cpu },
{ name: 'Sprout', icon: Sprout },
{ name: 'Shirt', icon: Shirt },
{ name: 'Sparkles', icon: Sparkles },
{ name: 'Utensils', icon: Utensils },
{ name: 'HardHat', icon: HardHat },
{ name: 'Stethoscope', icon: Stethoscope },
{ name: 'GraduationCap', icon: GraduationCap },
{ name: 'Palette', icon: Palette },
{ name: 'Plane', icon: Plane },
{ name: 'Truck', icon: Truck },
{ name: 'Wallet', icon: Wallet },
{ name: 'Zap', icon: Zap },
{ name: 'Leaf', icon: Leaf },
{ name: 'Camera', icon: Camera },
{ name: 'Music', icon: Music },
{ name: 'ShoppingBag', icon: ShoppingBag },
{ name: 'Heart', icon: Heart },
{ name: 'Home', icon: Home },
{ name: 'Tags', icon: Tags },
];
type Tab = 'categories' | 'tags';
export default function TaxonomiesClient() {
const [activeTab, setActiveTab] = useState<Tab>('categories');
const [categories, setCategories] = useState<any[]>([]);
const [suggestions, setSuggestions] = useState<any[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [isPending, startTransition] = useTransition();
// Category Form State
const [showAddModal, setShowAddModal] = useState(false);
const [editingCategory, setEditingCategory] = useState<any>(null);
const [formData, setFormData] = useState({
name: '',
slug: '',
icon: 'Briefcase',
isActive: true
});
// Tag Form State
const [newTagName, setNewTagName] = useState('');
const [isEditingUsageName, setIsEditingUsageName] = useState(false);
const [usagePreview, setUsagePreview] = useState<{ tag: string, data: any } | null>(null);
const [loadingUsage, setLoadingUsage] = useState(false);
const loadData = async () => {
setLoading(true);
try {
const [catsRes, tagsRes, sugRes] = await Promise.all([
getCategories(),
getAllTags(),
getSuggestedCategories()
]);
setCategories(catsRes);
setTags(tagsRes);
setSuggestions(sugRes);
} catch (err) {
toast.error("Erreur de chargement");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleCategorySubmit = async (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
const result = editingCategory
? await updateCategory(editingCategory.id, formData)
: await createCategory(formData);
if (result.success) {
toast.success(editingCategory ? "Catégorie mise à jour" : "Catégorie créée");
setShowAddModal(false);
setEditingCategory(null);
setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
loadData();
} else {
toast.error(result.error || "Erreur");
}
});
};
const handleTagDelete = async (tag: string) => {
if (!confirm(`Supprimer le tag "${tag}" globalement ?`)) return;
const result = await deleteTagGlobally(tag);
if (result.success) {
toast.success("Tag supprimé partout");
loadData();
} else {
toast.error(result.error || "Erreur");
}
};
const handleShowUsage = async (tag: string) => {
setLoadingUsage(true);
const result = await getTagUsage(tag);
if (result.success) {
setUsagePreview({ tag, data: result.data });
}
setLoadingUsage(false);
};
if (loading) return <div className="p-8 text-center text-slate-500">Chargement...</div>;
return (
<div className="space-y-8">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Taxonomies</h1>
<p className="text-slate-400">Gérez les catégories et les mots-clés du site.</p>
</div>
<div className="flex bg-slate-900 p-1 rounded-xl border border-slate-800">
<button
onClick={() => setActiveTab('categories')}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'categories' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
>
Catégories
</button>
<button
onClick={() => setActiveTab('tags')}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'tags' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
>
Tags / Mots-clés
</button>
</div>
</div>
{activeTab === 'categories' ? (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Tags className="w-5 h-5 text-indigo-400" />
Liste des catégories ({categories.length})
</h2>
<button
onClick={() => { setEditingCategory(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); setShowAddModal(true); }}
className="btn-verify flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Nouvelle catégorie
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map((cat) => {
const IconComp = ICON_LIST.find(i => i.name === cat.icon)?.icon || Briefcase;
return (
<div key={cat.id} className="card p-4 flex items-center justify-between group">
<div className="flex items-center gap-4">
<div className="p-2 bg-slate-800 rounded-lg text-indigo-400">
<IconComp className="w-5 h-5" />
</div>
<div>
<h3 className="text-white font-bold">{cat.name}</h3>
<p className="text-xs text-slate-500">/{cat.slug}</p>
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => { setEditingCategory(cat); setFormData({ name: cat.name, slug: cat.slug, icon: cat.icon, isActive: cat.isActive }); setShowAddModal(true); }}
className="p-2 text-slate-400 hover:text-indigo-400 transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={async () => { if (confirm("Supprimer ?")) await deleteCategory(cat.id); loadData(); }}
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
) : (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Hash className="w-5 h-5 text-indigo-400" />
Tags utilisés ({tags.length})
</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{tags.map((tag) => (
<div
key={tag}
onClick={() => handleShowUsage(tag)}
className="group flex items-center justify-between bg-slate-950 border border-slate-800 rounded-xl p-3 hover:border-indigo-500/50 transition-all hover:bg-indigo-500/5 cursor-pointer"
>
<span className="text-sm font-bold text-slate-300 truncate group-hover:text-white">{tag}</span>
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<Trash2
className="w-3.5 h-3.5 text-slate-500 hover:text-red-400"
onClick={(e) => { e.stopPropagation(); handleTagDelete(tag); }}
/>
</div>
</div>
))}
</div>
</div>
)}
{/* Modal Ajout/Modif Catégorie */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<div className="bg-slate-900 border border-slate-700 rounded-3xl p-8 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-6">{editingCategory ? 'Modifier la catégorie' : 'Nouvelle catégorie'}</h3>
<form onSubmit={handleCategorySubmit} className="space-y-4">
<div>
<label className="block text-sm font-bold text-slate-400 mb-2 uppercase tracking-wider">Nom</label>
<input
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-white focus:border-indigo-500 outline-none transition-all"
/>
</div>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto p-1">
{ICON_LIST.map((item) => (
<button
key={item.name}
type="button"
onClick={() => setFormData({ ...formData, icon: item.name })}
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${formData.icon === item.name ? 'bg-indigo-600/20 border-indigo-500 text-indigo-400' : 'bg-slate-950 border-slate-800 text-slate-500 hover:border-slate-700'}`}
>
<item.icon className="w-5 h-5" />
</button>
))}
</div>
<div className="flex gap-3 mt-8">
<button type="button" onClick={() => setShowAddModal(false)} className="flex-1 px-4 py-3 bg-slate-800 text-white rounded-xl font-bold hover:bg-slate-700 transition-all">Annuler</button>
<button type="submit" disabled={isPending} className="flex-1 px-4 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all flex items-center justify-center gap-2">
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
{editingCategory ? 'Mettre à jour' : 'Créer'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Usage Modal */}
{usagePreview && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<div className="bg-slate-900 border border-slate-700 rounded-3xl w-full max-w-4xl overflow-hidden shadow-2xl">
<div className="p-6 border-b border-slate-700 bg-slate-800/30 flex justify-between items-center">
<div className="flex items-center gap-3">
<Hash className="w-6 h-6 text-indigo-500" />
{isEditingUsageName ? (
<div className="flex items-center gap-2">
<input
autoFocus
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
className="bg-slate-950 border border-indigo-500/50 rounded-xl px-4 py-2 text-white font-bold"
/>
<button
onClick={async () => {
const res = await renameTagGlobally(usagePreview.tag, newTagName);
if (res.success) {
toast.success("Renommé");
setUsagePreview({ ...usagePreview, tag: newTagName });
setIsEditingUsageName(false);
loadData();
}
}}
className="p-2 bg-emerald-600 text-white rounded-lg"
>
<Check className="w-4 h-4" />
</button>
</div>
) : (
<h3 className="text-xl font-bold text-white uppercase tracking-tight">Tag : {usagePreview.tag}</h3>
)}
</div>
<div className="flex items-center gap-4">
{!isEditingUsageName && (
<button onClick={() => { setNewTagName(usagePreview.tag); setIsEditingUsageName(true); }} className="p-2 text-slate-400 hover:text-indigo-400"><Edit2 className="w-4 h-4" /></button>
)}
<button onClick={() => setUsagePreview(null)} className="p-2 text-slate-400 hover:text-white"><X className="w-6 h-6" /></button>
</div>
</div>
<div className="p-8 grid grid-cols-1 md:grid-cols-3 gap-6 max-h-[60vh] overflow-y-auto custom-scrollbar">
{/* Blog Posts */}
<div className="space-y-4">
<div className="flex items-center gap-2 px-1">
<div className="p-1.5 bg-indigo-500/10 rounded-lg">
<BookOpen className="w-3.5 h-3.5 text-indigo-400" />
</div>
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Articles de Blog ({usagePreview.data.blogPosts.length})</h4>
</div>
<div className="space-y-2">
{usagePreview.data.blogPosts.length === 0 ? (
<p className="text-[10px] text-slate-600 italic px-2">Aucun article</p>
) : usagePreview.data.blogPosts.map((post: any) => (
<div key={post.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-indigo-500/30 transition-all">
<span className="text-sm font-medium text-slate-200 truncate pr-2">{post.title}</span>
<Link href={`/blog/edit/${post.id}`} className="p-1.5 bg-slate-800 hover:bg-indigo-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
<Edit2 className="w-3 h-3" />
</Link>
</div>
))}
</div>
</div>
{/* Interviews */}
<div className="space-y-4">
<div className="flex items-center gap-2 px-1">
<div className="p-1.5 bg-emerald-500/10 rounded-lg">
<UserIcon className="w-3.5 h-3.5 text-emerald-400" />
</div>
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Interviews ({usagePreview.data.interviews.length})</h4>
</div>
<div className="space-y-2">
{usagePreview.data.interviews.length === 0 ? (
<p className="text-[10px] text-slate-600 italic px-2">Aucune interview</p>
) : usagePreview.data.interviews.map((item: any) => (
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-emerald-500/30 transition-all">
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
<Link href={`/interview/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-emerald-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
<Edit2 className="w-3 h-3" />
</Link>
</div>
))}
</div>
</div>
{/* Events */}
<div className="space-y-4">
<div className="flex items-center gap-2 px-1">
<div className="p-1.5 bg-amber-500/10 rounded-lg">
<Clock className="w-3.5 h-3.5 text-amber-400" />
</div>
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Événements ({usagePreview.data.events.length})</h4>
</div>
<div className="space-y-2">
{usagePreview.data.events.length === 0 ? (
<p className="text-[10px] text-slate-600 italic px-2">Aucun événement</p>
) : usagePreview.data.events.map((item: any) => (
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-amber-500/30 transition-all">
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
<Link href={`/event/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-amber-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
<Edit2 className="w-3 h-3" />
</Link>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import TaxonomiesClient from "./TaxonomiesClient";
export default function TaxonomiesPage() {
return <TaxonomiesClient />;
}

View File

@@ -0,0 +1,316 @@
import { prisma } from '@/lib/prisma';
import { getClients } from "@/app/actions/user";
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
import FeaturedModal from '@/components/FeaturedModal';
import ToggleHomeFeatureButton from '@/components/ToggleHomeFeatureButton';
import EntrepreneurActions from '@/components/EntrepreneurActions';
import PlanSelector from '@/components/PlanSelector';
import ClientActions from "@/components/ClientActions";
import ManualVerifyButton from "@/components/ManualVerifyButton";
import { Trash2, ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
import Link from 'next/link';
// Force Turbopack native load
async function getBusinesses() {
return await prisma.business.findMany({
where: {
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">
<div className="overflow-x-auto">
<table className="data-table min-w-[1200px]">
<thead>
<tr>
<th className="sticky left-0 z-30 bg-[#1e293b] border-r border-slate-700">Entreprise</th>
<th>Catégorie</th>
<th>Propriétaire</th>
<th>Email</th>
<th>Statut</th>
<th className="text-center">Forfait</th>
<th className="text-center whitespace-nowrap">À la une (Accueil)</th>
<th className="text-center whitespace-nowrap">Afroshine (Annuaire)</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody>
{businesses.length === 0 ? (
<tr>
<td colSpan={9} className="text-center py-10 text-slate-500">
Aucun entrepreneur trouvé.
</td>
</tr>
) : (
businesses.map((business: any) => (
<tr key={business.id} className={`transition-colors ${business.owner?.deletedAt ? 'bg-red-500/5' : (business.isSuspended || business.owner?.isSuspended) ? 'grayscale-[0.5]' : ''}`}>
<td className="sticky left-0 z-20 bg-[#1e293b] border-r border-slate-700">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
{business.logoUrl ? (
<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 || 'Inconnu'}</div>
<div className="text-xs text-slate-500">{business.owner?.email || 'N/A'}</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>
{business.owner?.id && <ManualVerifyButton userId={business.owner.id} />}
</div>
)}
</td>
<td>
<div className="flex flex-col gap-1">
{business.owner?.deletedAt ? (
<span className="badge bg-red-500/10 text-red-500 border border-red-500/20 flex items-center gap-1">
<Trash2 className="w-3 h-3" /> Suppression en cours
</span>
) : business.owner?.isSuspended ? (
<span className="badge badge-error flex items-center gap-1">
<ShieldX className="w-3 h-3" /> Compte Suspendu
</span>
) : 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">
<ToggleHomeFeatureButton id={business.id} isHomeFeatured={!!business.isHomeFeatured} />
</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 || 'Inconnu'}
isBusinessSuspended={!!business.isSuspended}
isUserSuspended={!!business.owner?.isSuspended}
metaTitle={business.metaTitle}
metaDescription={business.metaDescription}
/>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
) : (
<div className="card overflow-hidden p-0">
<div className="overflow-x-auto">
<table className="data-table min-w-[1000px]">
<thead>
<tr>
<th className="sticky left-0 z-30 bg-[#1e293b] border-r border-slate-700">Utilisateur</th>
<th>Contact</th>
<th>Vérification</th>
<th>Localisation</th>
<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.deletedAt ? 'bg-red-500/5' : client.isSuspended ? 'grayscale-[0.5]' : ''}`}>
<td className="sticky left-0 z-20 bg-[#1e293b] border-r border-slate-700 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
{client.avatar ? (
<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.deletedAt ? (
<span className="badge bg-red-500/10 text-red-500 border border-red-500/20 flex items-center gap-1">
<Trash2 className="w-3 h-3" /> Suppression en cours
</span>
) : client.isSuspended ? (
<>
<span className="badge badge-error">Compte Suspendu</span>
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
{client.suspensionReason}
</span>
</>
) : client.businesses ? (
<span className="badge badge-pending">Shop Inactif</span>
) : (
<span className="badge bg-slate-800 text-slate-400">Actif</span>
)}
</div>
</td>
<td className="text-right">
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
"use client";
import React, { useTransition } from 'react';
import { Check, X, Loader2 } from 'lucide-react';
import { approveBlogPost, approveEvent, rejectBlogPost, rejectEvent } from '@/app/actions/approval';
import { toast } from 'react-hot-toast';
interface ApproveContentButtonProps {
id: string;
type: 'post' | 'event';
action: 'approve' | 'reject';
}
export default function ApproveContentButton({ id, type, action }: ApproveContentButtonProps) {
const [isPending, startTransition] = useTransition();
const handleAction = async () => {
if (action === 'reject' && !confirm("Êtes-vous sûr de vouloir rejeter ce contenu ?")) {
return;
}
startTransition(async () => {
let result;
if (type === 'post') {
result = action === 'approve' ? await approveBlogPost(id) : await rejectBlogPost(id);
} else {
result = action === 'approve' ? await approveEvent(id) : await rejectEvent(id);
}
if (result.success) {
toast.success(action === 'approve' ? "Contenu approuvé et publié" : "Contenu rejeté");
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
if (action === 'approve') {
return (
<button
onClick={handleAction}
disabled={isPending}
className="p-2 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 rounded-lg transition-colors disabled:opacity-50"
title="Approuver et Publier"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Check className="w-5 h-5" />}
</button>
);
}
return (
<button
onClick={handleAction}
disabled={isPending}
className="p-2 bg-red-500/10 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors disabled:opacity-50"
title="Rejeter"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <X className="w-5 h-5" />}
</button>
);
}

View File

@@ -1,199 +0,0 @@
"use client";
import { useTransition, useState } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
import { Loader2, ArrowLeft, Save } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
interface Props {
initialData?: {
id: string;
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
slug?: string | null;
tags?: string[];
metaTitle?: string | null;
metaDescription?: string | null;
};
}
export default function BlogForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || '');
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = {
title: formData.get('title') as string,
slug: formData.get('slug') as string,
excerpt: formData.get('excerpt') as string,
content: content,
author: formData.get('author') as string,
imageUrl: formData.get('imageUrl') as string,
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
};
startTransition(async () => {
const result = initialData
? await updateBlogPost(initialData.id, data)
: await createBlogPost(data);
if (result.success) {
toast.success(initialData ? "Article mis à jour" : "Article publié avec succès");
router.push('/blog');
router.refresh();
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
return (
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">
{initialData ? 'Modifier l\'article' : 'Nouvel Article'}
</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</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">Titre</label>
<input
name="title"
defaultValue={initialData?.title}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: L'essor de la Tech en Afrique"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Auteur</label>
<input
name="author"
defaultValue={initialData?.author}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Jean Dupont"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de l'image</label>
<input
name="imageUrl"
defaultValue={initialData?.imageUrl}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://images.unsplash.com/..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
<textarea
name="excerpt"
defaultValue={initialData?.excerpt}
required
rows={2}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Un court résumé de l'article..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Rédigez votre article ici..."
/>
</div>
</div>
{/* SEO SECTION */}
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
<input
name="slug"
defaultValue={initialData?.slug || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="ex: lessor-de-la-tech-afrique"
/>
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
<input
name="tags"
defaultValue={initialData?.tags?.join(', ') || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="tech, afrique, innovation"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input
name="metaTitle"
defaultValue={initialData?.metaTitle || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Titre optimisé pour les moteurs de recherche"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea
name="metaDescription"
defaultValue={initialData?.metaDescription || ''}
rows={3}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Description optimisée pour les moteurs de recherche (max 160 caractères)..."
/>
</div>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Publier l\'article'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import React, { useState, useEffect, useTransition } from 'react';
import { createPortal } from 'react-dom';
import { X, Save, Loader2, Globe } from 'lucide-react';
import { updateBusinessSeo } from '@/app/actions/business';
import { toast } from 'react-hot-toast';
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 || '');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!isOpen || !mounted) 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");
}
});
};
const modalContent = (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
<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>
);
return createPortal(modalContent, document.body);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
"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';
import DeleteAccountButton from './DeleteAccountButton';
interface EntrepreneurActionsProps { interface EntrepreneurActionsProps {
businessId: string; businessId: string;
@@ -13,6 +15,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 +25,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 +48,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
@@ -82,6 +99,10 @@ export default function EntrepreneurActions({
<span className="text-[10px] uppercase font-bold">Shop</span> <span className="text-[10px] uppercase font-bold">Shop</span>
</button> </button>
)} )}
<div className="h-8 w-[1px] bg-slate-800 mx-1 self-center" />
<DeleteAccountButton userId={userId} userName={userName} />
</div> </div>
<SuspensionModal <SuspensionModal
@@ -93,6 +114,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>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
"use client";
import { useTransition } from 'react';
import { verifyUserEmail } from '@/app/actions/user';
import { MailCheck, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface Props {
userId: string;
}
export default function ManualVerifyButton({ userId }: Props) {
const [isPending, startTransition] = useTransition();
const handleVerify = () => {
startTransition(async () => {
const result = await verifyUserEmail(userId);
if (result.success) {
toast.success("Email vérifié avec succès");
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
return (
<button
onClick={handleVerify}
disabled={isPending}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-500/10 text-indigo-500 hover:bg-indigo-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-indigo-500/20"
title="Vérifier manuellement l'email"
>
{isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<MailCheck className="w-3 h-3" />
)}
Vérifier Email
</button>
);
}

View File

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

View File

@@ -9,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">

View File

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

View File

@@ -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' },
]; ];
@@ -43,7 +47,7 @@ export default function Sidebar() {
}, [pathname]); // Refresh on navigation }, [pathname]); // Refresh on navigation
return ( return (
<div className="admin-sidebar p-6 flex flex-col"> <div className="admin-sidebar z-50 p-6 flex flex-col">
<div className="flex items-center gap-3 mb-10 px-2"> <div className="flex items-center gap-3 mb-10 px-2">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center"> <div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
<ShieldCheck className="text-white" /> <ShieldCheck className="text-white" />
@@ -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>

View File

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

View File

@@ -0,0 +1,180 @@
'use client';
import React, { useState, KeyboardEvent, useEffect, useRef } from 'react';
import { X, Hash, Search, TrendingUp } from 'lucide-react';
interface Props {
value: string[];
onChange: (value: string[]) => void;
label?: string;
placeholder?: string;
suggestions?: string[];
}
export default function TagInput({ value, onChange, label, placeholder, suggestions = [] }: Props) {
const [inputValue, setInputValue] = useState('');
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inputValue.trim()) {
const filtered = suggestions
.filter(tag =>
tag.toLowerCase().includes(inputValue.toLowerCase()) &&
!value.includes(tag.toLowerCase())
)
.slice(0, 10);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else if (showSuggestions && suggestions.length > 0) {
// Show popular/all suggestions when empty but focused
const filtered = suggestions
.filter(tag => !value.includes(tag.toLowerCase()))
.slice(0, 8);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else {
setFilteredSuggestions([]);
setShowSuggestions(false);
}
setSelectedIndex(-1);
}, [inputValue, suggestions, value, showSuggestions]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const addTag = (tagToAdd?: string) => {
const tag = (tagToAdd || inputValue).trim().toLowerCase();
if (tag && !value.includes(tag)) {
onChange([...value, tag]);
}
setInputValue('');
// Keep focus if we added via suggestion
if (tagToAdd) setShowSuggestions(true);
};
const removeTag = (tagToRemove: string) => {
onChange(value.filter(tag => tag !== tagToRemove));
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
addTag(filteredSuggestions[selectedIndex]);
} else {
addTag();
}
} else if (e.key === ',' || e.key === ';') {
e.preventDefault();
addTag();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (!showSuggestions && suggestions.length > 0) {
setShowSuggestions(true);
} else {
setSelectedIndex(prev => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev));
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => (prev > -1 ? prev - 1 : -1));
} else if (e.key === 'Escape') {
setShowSuggestions(false);
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
removeTag(value[value.length - 1]);
}
};
return (
<div className="space-y-2 relative" ref={containerRef}>
{label && <label className="text-sm font-medium text-slate-400">{label}</label>}
<div
className="min-h-[48px] p-1.5 bg-slate-900 border border-slate-700 rounded-lg flex flex-wrap gap-2 items-center focus-within:border-indigo-500 transition-colors cursor-text"
onClick={() => {
const input = containerRef.current?.querySelector('input');
input?.focus();
}}
>
{value.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 px-2.5 py-1 rounded-md text-sm font-medium animate-in zoom-in-95 duration-200"
>
<Hash className="w-3 h-3 opacity-50" />
{tag}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
className="hover:text-red-400 transition-colors ml-1"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(true)}
placeholder={value.length === 0 ? (placeholder || "Ajouter des tags...") : ""}
className="flex-1 bg-transparent border-none outline-none text-white text-sm min-w-[120px] p-1 placeholder:text-slate-600"
/>
</div>
{/* Suggestions Popover */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-b border-slate-700 bg-slate-900/50 flex items-center justify-between">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest flex items-center gap-1.5">
{inputValue ? <Search className="w-3 h-3" /> : <TrendingUp className="w-3 h-3" />}
{inputValue ? 'Suggestions de recherche' : 'Mots-clés suggérés'}
</p>
{!inputValue && <span className="text-[9px] text-slate-600 font-medium">Top {filteredSuggestions.length}</span>}
</div>
<div className="max-h-60 overflow-y-auto custom-scrollbar">
{filteredSuggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
onClick={(e) => {
e.stopPropagation();
addTag(suggestion);
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
index === selectedIndex ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700'
}`}
>
<span className="flex items-center gap-2">
<Hash className={`w-3.5 h-3.5 ${index === selectedIndex ? 'text-white/50' : 'text-slate-500'}`} />
{suggestion}
</span>
{index === selectedIndex && (
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded font-bold uppercase">Entrée</span>
)}
</button>
))}
</div>
</div>
)}
<p className="text-[10px] text-slate-500 italic">
Appuyez sur Entrée ou virgule pour ajouter un tag.
</p>
</div>
);
}

View File

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

View File

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

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

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

View File

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

133
app/HomeClient.tsx Normal file
View File

@@ -0,0 +1,133 @@
"use client";
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
import { generateSlug } from '@/lib/utils';
import { Business, BlogPost } from '@/types';
import BusinessCard from '@/components/BusinessCard';
import { useUser } from '@/components/UserProvider';
import HomeSlider from '@/components/HomeSlider';
interface Props {
initialFeatured: Business[];
initialPosts: BlogPost[];
initialCategories: any[];
initialSlides: any[];
}
const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialSlides }: Props) => {
const router = useRouter();
const { user, settings } = useUser();
const [searchTerm, setSearchTerm] = useState('');
const [locationTerm, setLocationTerm] = useState('');
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
const [categories, setCategories] = useState<any[]>(initialCategories);
const [slides, setSlides] = useState<any[]>(initialSlides);
const [loading, setLoading] = useState(false);
const [loadingPosts, setLoadingPosts] = useState(false);
const handleSearch = (searchTerm: string, locationTerm: string) => {
const params = new URLSearchParams();
if (searchTerm) params.append('q', searchTerm);
if (locationTerm) params.append('location', locationTerm);
router.push(`/annuaire?${params.toString()}`);
};
return (
<div>
{/* Dynamic Hero Slider */}
<HomeSlider slides={slides} onSearch={handleSearch} />
{/* Featured Categories */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<h2 className="text-3xl font-bold text-gray-900 mb-10 font-serif text-center">Secteurs en vedette</h2>
<div className="flex flex-wrap justify-center gap-4 sm:gap-6">
{(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
<Link
key={idx}
href={`/annuaire?category=${encodeURIComponent(cat)}`}
className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center w-[calc(50%-0.5rem)] sm:w-48 lg:w-56 flex flex-col items-center"
>
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
<Briefcase className="w-6 h-6" />
</div>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base line-clamp-2">{cat}</h3>
</Link>
))}
</div>
</div>
{/* Featured Businesses */}
<div className="bg-gray-50 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">Entreprises à la une</h2>
<p className="text-gray-500 mt-2">Découvrez les pépites de notre communauté.</p>
</div>
<Link href="/annuaire" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{featuredBusinesses.map(biz => (
<BusinessCard key={biz.id} business={biz} />
))}
</div>
</div>
</div>
{/* Blog Section */}
{settings?.homeBlogShow !== false && (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
</div>
<Link href="/actualites" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir les actualités <ArrowRight className="w-4 h-4 ml-1" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts
.filter(post => {
if (settings?.homeBlogSelection === 'manual') {
return settings.homeBlogIds?.includes(post.id);
}
if (settings?.homeBlogSelection === 'category') {
return post.tags?.some((tag: string) => settings.homeBlogCategories?.includes(tag));
}
return true;
})
.slice(0, settings?.homeBlogCount || 3)
.map(post => (
<Link key={post.id} href={`/actualites/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
<div className="h-48 overflow-hidden">
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
</div>
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-brand-600 transition-colors">{post.title}</h3>
<p className="text-gray-500 text-sm line-clamp-3 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between mt-auto pt-4 border-t border-gray-50">
<span className="text-xs font-medium text-gray-400">{new Date(post.date).toLocaleDateString('fr-FR')}</span>
<span className="text-brand-600 font-bold text-xs">Lire la suite </span>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default HomeClient;

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

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

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

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

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

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

34
app/actions/upload.ts Normal file
View File

@@ -0,0 +1,34 @@
'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');
let mimeType = file.type || 'image/jpeg';
// Normalize JPEG mime types
if (mimeType === 'image/jpg' || mimeType === 'image/pjpeg') {
mimeType = 'image/jpeg';
}
const dataUrl = `data:${mimeType};base64,${base64}`;
// Return the data URL directly (it will be saved in the database)
return {
success: true,
url: dataUrl
};
} catch (error) {
console.error('Error during image conversion:', error);
return { success: false, error: 'Erreur lors de la conversion de l\'image' };
}
}

View File

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

View File

@@ -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,9 +40,18 @@ export default async function BlogPage() {
) : ( ) : (
<div className="grid gap-8 lg:grid-cols-3"> <div className="grid gap-8 lg:grid-cols-3">
{posts.map(post => ( {posts.map(post => (
<Link key={post.id} href={`/blog/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300"> <Link key={post.id} href={`/actualites/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
<div className="flex-shrink-0 relative overflow-hidden h-48"> <div className="flex-shrink-0 relative overflow-hidden h-48">
<img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} /> <img
className="h-full w-full object-cover group-hover:scale-[1.05] transition-transform duration-500"
src={post.imageUrl}
alt={post.title}
style={{
objectPosition: post.coverPosition || '50% 50%',
transform: `scale(${post.coverZoom || 1})`,
transformOrigin: post.coverPosition || '50% 50%'
}}
/>
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div> <div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
</div> </div>
<div className="flex-1 bg-white p-6 flex flex-col justify-between"> <div className="flex-1 bg-white p-6 flex flex-col justify-between">

View File

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

View File

@@ -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,64 @@ 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-[1.05] transition-transform duration-700"
item.type === 'EVENT' ? <Calendar className="w-3 h-3 mr-1" /> : <Mic className="w-3 h-3 mr-1" />} style={{
{item.type === 'VIDEO' ? 'Vidéo' : objectPosition: item.coverPosition || '50% 50%',
item.type === 'EVENT' ? 'Événement' : 'Interview'} transform: `scale(${item.coverZoom || 1})`,
</span> transformOrigin: item.coverPosition || '50% 50%'
</div> }}
/>
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></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>

View File

@@ -0,0 +1,666 @@
"use client";
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Twitter, Play, Share2, Send, Award, Quote, MessageCircle, Lock, Loader2, Link2, ShoppingBag, Heart } from 'lucide-react';
import { Business, OfferType, Rating } from '@/types';
import { toast } from 'react-hot-toast';
import { useUser } from '@/components/UserProvider';
interface Props {
initialBusiness: Business;
}
const BusinessDetailClient = ({ initialBusiness }: Props) => {
const { id } = useParams();
const router = useRouter();
const { user } = useUser();
const [business, setBusiness] = useState<Business>(initialBusiness);
const [loading, setLoading] = useState(false);
const [existingConversationId, setExistingConversationId] = useState<string | null>(null);
const [isShareOpen, setIsShareOpen] = useState(false);
const [selectedOffer, setSelectedOffer] = useState<any | null>(null);
const [userRating, setUserRating] = useState<number | null>(null);
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [isRating, setIsRating] = useState(false);
const [ratings, setRatings] = useState<Rating[]>([]);
const [reviewComment, setReviewComment] = useState('');
const [userRatingStatus, setUserRatingStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED' | null>(null);
const [loadingRatings, setLoadingRatings] = useState(false);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [isFavorited, setIsFavorited] = useState(false);
const [isTogglingFavorite, setIsTogglingFavorite] = useState(false);
const isOwner = user && business && user.id === business.ownerId;
const contactRef = React.useRef<HTMLDivElement>(null);
const [contactForm, setContactForm] = useState({
name: '',
email: '',
message: ''
});
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
const handleOrder = (offer: any) => {
setContactForm(prev => ({
...prev,
message: `Bonjour, je souhaiterais commander ou avoir plus d'informations sur votre offre : "${offer.title}". Pouvez-vous me recontacter ?`
}));
setSelectedOffer(null);
contactRef.current?.scrollIntoView({ behavior: 'smooth' });
setTimeout(() => {
const textarea = document.getElementById('contact-message');
if (textarea) textarea.focus();
}, 800);
toast.success("Message préparé ! Vous pouvez l'envoyer ci-dessous.");
};
const businessOffers = useMemo(() => {
if (!business) return [];
if (business.offers && business.offers.length > 0) return business.offers;
return [];
}, [business]);
useEffect(() => {
const loadExtras = async () => {
// Track view event if not owner
if (!user || user.id !== business.ownerId) {
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'BUSINESS_VIEW',
path: window.location.pathname,
label: business.name,
metadata: {
businessId: business.id,
businessName: business.name,
userId: user?.id || 'anonymous'
}
})
}).catch(console.error);
fetch(`/api/businesses/${business.id}/view`, {
method: 'POST',
headers: user ? { 'x-user-id': user.id } : {}
}).catch(console.error);
}
// Fetch user rating if logged in
if (user) {
try {
const ratingRes = await fetch(`/api/businesses/${business.id}/rating/me`, {
headers: { 'x-user-id': user.id }
});
const ratingData = await ratingRes.json();
if (ratingData.rating) {
setUserRating(ratingData.rating);
setReviewComment(ratingData.comment || '');
setUserRatingStatus(ratingData.status);
}
} catch (e) { console.error(e); }
// Fetch favorite status
try {
const favRes = await fetch('/api/favorites', {
headers: { 'x-user-id': user.id }
});
const favData = await favRes.json();
if (Array.isArray(favData)) {
setIsFavorited(favData.some((f: any) => f.businessId === business.id));
}
} catch (e) { console.error(e); }
}
// Fetch all ratings
try {
setLoadingRatings(true);
const ratingsRes = await fetch(`/api/businesses/${business.id}/ratings`, {
headers: user ? { 'x-user-id': user.id } : {}
});
if (ratingsRes.ok) {
const ratingsData = await ratingsRes.json();
setRatings(ratingsData);
}
} catch (e) { console.error(e); } finally { setLoadingRatings(false); }
};
loadExtras();
}, [business.id, user]);
useEffect(() => {
const checkExistingConversation = async () => {
if (!user || user.id === 'undefined' || !business?.id) return;
try {
const res = await fetch(`/api/conversations?businessId=${business.id}`, {
headers: { 'x-user-id': user.id }
});
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
setExistingConversationId(data[0].id);
}
} catch (error) { console.error(error); }
};
if (user && business) checkExistingConversation();
}, [user, business]);
const getEmbedUrl = (url: string) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? `https://www.youtube.com/embed/${match[2]}` : null;
};
const videoEmbedUrl = business.videoUrl ? getEmbedUrl(business.videoUrl) : null;
const handleTrackEvent = async (type: string, label?: string) => {
if (!business?.id || (user && user.id === business.ownerId)) return;
try {
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
path: window.location.pathname,
label: label || business.name,
metadata: {
businessId: business.id,
businessName: business.name,
userId: user?.id || 'anonymous'
}
})
});
} catch (e) { console.error(e); }
};
const handleContactSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) {
toast.error('Veuillez vous connecter pour envoyer un message');
router.push('/login');
return;
}
setFormStatus('sending');
try {
const res = await fetch('/api/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user.id
},
body: JSON.stringify({
businessId: business.id,
content: contactForm.message
})
});
const data = await res.json();
if (!data.error) {
handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Message Sent');
setFormStatus('success');
toast.success('Message envoyé ! Redirection vers votre messagerie...');
setContactForm({ ...contactForm, message: '' });
setTimeout(() => router.push('/dashboard?view=messages'), 2000);
} else {
toast.error(data.error);
setFormStatus('idle');
}
} catch (error) {
toast.error("Erreur lors de l'envoi");
setFormStatus('idle');
}
};
const handleShare = (platform: string) => {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(`Découvrez ${business.name} sur Afrohub`);
let shareLink = '';
switch(platform) {
case 'facebook': shareLink = `https://www.facebook.com/sharer/sharer.php?u=${url}`; break;
case 'twitter': shareLink = `https://twitter.com/intent/tweet?url=${url}&text=${text}`; break;
case 'linkedin': shareLink = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`; break;
case 'copy':
navigator.clipboard.writeText(window.location.href);
toast.success("Lien copié !");
setIsShareOpen(false);
return;
}
if (shareLink) window.open(shareLink, '_blank', 'width=600,height=400');
setIsShareOpen(false);
};
const handleRate = async (value: number) => {
if (!user) { toast.error('Connectez-vous pour voter'); router.push('/login'); return; }
if (isOwner) { toast.error("Vous ne pouvez pas noter votre propre entreprise"); return; }
if (isRating) return;
setIsRating(true);
try {
const res = await fetch(`/api/businesses/${business.id}/rate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
body: JSON.stringify({ value, comment: reviewComment })
});
const data = await res.json();
if (!data.error) {
setUserRating(value);
setBusiness(prev => ({ ...prev, rating: data.rating, ratingCount: data.ratingCount }));
toast.success('Merci pour votre vote !');
handleTrackEvent('BUSINESS_RATING_CAST', `Stars: ${value}`);
const rRes = await fetch(`/api/businesses/${business.id}/ratings`);
if (rRes.ok) setRatings(await rRes.json());
} else toast.error(data.error);
} catch (error) { toast.error("Erreur vote"); } finally { setIsRating(false); }
};
const handleReply = async (ratingId: string) => {
if (!user || !isOwner || !replyText.trim()) return;
setIsSubmittingReply(true);
try {
const res = await fetch(`/api/ratings/${ratingId}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
body: JSON.stringify({ reply: replyText })
});
if (res.ok) {
toast.success("Réponse publiée !");
setReplyingTo(null);
setReplyText('');
const rRes = await fetch(`/api/businesses/${business.id}/ratings`);
if (rRes.ok) setRatings(await rRes.json());
} else toast.error("Erreur réponse");
} catch (e) { toast.error("Erreur connexion"); } finally { setIsSubmittingReply(false); }
};
const toggleFavorite = async () => {
if (!user) { toast.error("Connectez-vous"); router.push('/login'); return; }
setIsTogglingFavorite(true);
try {
const res = await fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
body: JSON.stringify({ businessId: business.id })
});
const data = await res.json();
if (!data.error) {
setIsFavorited(data.favorited);
toast.success(data.favorited ? 'Ajouté aux favoris' : 'Retiré des favoris');
}
} catch (error) { toast.error('Erreur'); } finally { setIsTogglingFavorite(false); }
};
return (
<div className="bg-gray-50 min-h-screen pb-12">
{/* Header Banner */}
<div className="h-48 md:h-64 bg-gray-900 relative overflow-hidden">
{business.coverUrl ? (
<img
src={business.coverUrl}
alt="Couverture"
className="absolute inset-0 w-full h-full object-cover opacity-60"
style={{
objectPosition: business.coverPosition || '50% 50%',
transform: `scale(${business.coverZoom || 1})`
}}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-r from-gray-900 to-gray-800 opacity-100">
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] pointer-events-none"></div>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex flex-col justify-between py-6 relative z-10">
<button
onClick={() => router.back()}
className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit group active:scale-95"
>
<ArrowLeft className="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" /> Retour
</button>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 relative z-10">
<div className="bg-white rounded-xl shadow-lg border border-gray-100">
<div className="p-6 md:p-8">
{/* Profile Header */}
<div className="flex flex-col md:flex-row gap-6 items-start">
<div className="w-32 h-32 md:w-40 md:h-40 rounded-xl bg-white p-1 shadow-md -mt-16 md:-mt-24 border border-gray-100 flex-shrink-0 relative z-20">
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-lg bg-gray-50" />
{business.isFeatured && (
<div className="absolute -top-3 -right-3 bg-yellow-400 text-yellow-900 rounded-full p-2 shadow-md" title="Afroshine">
<Award className="w-6 h-6" />
</div>
)}
</div>
<div className="flex-1 w-full">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-serif font-bold text-gray-900 flex items-center gap-2">
{business.name}
</h1>
{business.verified && <span title="Entreprise Vérifiée"><CheckCircle className="w-6 h-6 text-blue-500" /></span>}
</div>
<div className="text-brand-600 font-medium mt-1 uppercase tracking-wide text-sm">{business.category}</div>
</div>
<div className="flex gap-3 relative">
<div className="relative">
<button
onClick={() => setIsShareOpen(!isShareOpen)}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 transition-all active:scale-95"
>
<Share2 className="w-4 h-4 mr-2" /> Partager
</button>
{isShareOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-2xl py-3 border border-gray-100 z-50 animate-in fade-in zoom-in duration-200">
<button onClick={() => handleShare('facebook')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-600">
<Facebook className="w-4 h-4 mr-3" /> Facebook
</button>
<button onClick={() => handleShare('twitter')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-black">
<Twitter className="w-4 h-4 mr-3" /> X (Twitter)
</button>
<button onClick={() => handleShare('copy')} className="w-full text-left px-4 py-2.5 text-sm font-medium text-brand-600 hover:bg-brand-50 flex items-center">
<Link2 className="w-4 h-4 mr-3" /> Copier le lien
</button>
</div>
)}
</div>
<button
onClick={toggleFavorite}
disabled={isTogglingFavorite}
className={`inline-flex items-center px-4 py-2 border shadow-sm text-sm font-medium rounded-md transition-all active:scale-95 ${isFavorited ? 'bg-red-500 border-red-500 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
<Heart className={`w-4 h-4 mr-2 ${isFavorited ? 'fill-current' : ''}`} />
{isFavorited ? 'Favori' : 'Mettre en favori'}
</button>
<a href="#contact-form" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700">
Contacter
</a>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-6 text-sm text-gray-500 border-t border-gray-100 pt-4">
<div className="flex items-center">
<MapPin className="w-4 h-4 mr-1 text-gray-400" />
{business.location}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-5 h-5 ${star <= Math.round(business.rating) ? 'text-yellow-400 fill-current' : 'text-gray-300'}`}
/>
))}
</div>
<span className="font-bold text-gray-900">{business.rating.toFixed(1)}</span>
<span className="text-gray-400">({business.ratingCount} votes {business.viewCount} vues)</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8">
<div className="lg:col-span-2 space-y-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4">À propos</h2>
<p className="text-gray-600 leading-relaxed whitespace-pre-line">{business.description}</p>
</div>
{videoEmbedUrl && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4 flex items-center">
<Play className="w-5 h-5 mr-2 text-brand-600" /> Présentation Vidéo
</h2>
<iframe src={videoEmbedUrl} className="w-full h-64 md:h-96 rounded-lg" allowFullScreen></iframe>
</div>
)}
{businessOffers.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Produits & Services</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{businessOffers.map(offer => (
<div key={offer.id} className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-all cursor-pointer group" onClick={() => handleOrder(offer)}>
<div className="h-40 bg-gray-100 relative">
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover" />
</div>
<div className="p-4">
<h3 className="font-bold text-gray-900">{offer.title}</h3>
<p className="text-brand-600 font-bold mt-1">{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Avis et Commentaires */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<h2 className="text-xl font-bold font-serif text-gray-900 flex items-center">
<Star className="w-5 h-5 mr-2 text-yellow-400 fill-current" /> Avis et Commentaires
</h2>
{!isOwner && (
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(null)}
onClick={() => handleRate(star)}
className="transition-transform active:scale-125 focus:outline-none"
>
<Star
className={`w-8 h-8 ${star <= (hoverRating || userRating || 0) ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
/>
</button>
))}
</div>
<span className="text-xs text-gray-500 font-medium italic">
{userRating ? "Merci pour votre note !" : "Cliquez sur une étoile pour voter"}
</span>
</div>
)}
</div>
{!isOwner && (
<div className="mb-10 bg-gray-50 rounded-xl p-5 border border-gray-100">
<p className="text-sm font-bold text-gray-700 mb-3">Laissez un commentaire</p>
<div className="relative">
<textarea
value={reviewComment}
onChange={(e) => setReviewComment(e.target.value)}
placeholder="Que pensez-vous de cette entreprise ? (Qualité, service...)"
className="w-full bg-white border border-gray-200 rounded-lg p-4 text-sm focus:ring-2 focus:ring-brand-500 outline-none h-24 resize-none shadow-sm"
/>
<button
onClick={() => handleRate(userRating || 5)}
disabled={isRating}
className="absolute bottom-3 right-3 bg-brand-600 text-white px-4 py-1.5 rounded-md text-xs font-bold hover:bg-brand-700 transition-all flex items-center gap-2 shadow-md"
>
{isRating ? <Loader2 className="w-3 h-3 animate-spin" /> : "Publier"}
</button>
</div>
</div>
)}
<div className="space-y-6">
{loadingRatings ? (
<div className="flex justify-center py-10">
<Loader2 className="w-8 h-8 text-brand-600 animate-spin" />
</div>
) : ratings.length > 0 ? (
ratings.map((rating) => (
<div key={rating.id} className="border-b border-gray-50 last:border-0 pb-6">
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-50 flex items-center justify-center text-brand-700 font-bold text-sm">
{(rating as any).user?.name?.charAt(0) || 'U'}
</div>
<div>
<p className="font-bold text-gray-900 text-sm">{(rating as any).user?.name}</p>
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-3 h-3 ${star <= rating.value ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
/>
))}
<span className="text-[10px] text-gray-400 ml-2 font-medium">
{new Date(rating.createdAt).toLocaleDateString('fr-FR')}
</span>
</div>
</div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed italic ml-13">
<Quote className="w-3 h-3 text-brand-200 inline mr-1" />
{rating.comment || "Pas de commentaire"}
</p>
{/* Reply section for owner */}
{isOwner && !rating.reply && (
<div className="mt-4 ml-13">
{replyingTo === rating.id ? (
<div className="flex flex-col gap-2">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Votre réponse..."
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm outline-none focus:ring-1 focus:ring-brand-500 h-20"
/>
<div className="flex gap-2">
<button onClick={() => handleReply(rating.id)} disabled={isSubmittingReply} className="bg-brand-600 text-white px-3 py-1.5 rounded text-xs font-bold">Répondre</button>
<button onClick={() => setReplyingTo(null)} className="text-gray-500 text-xs">Annuler</button>
</div>
</div>
) : (
<button onClick={() => setReplyingTo(rating.id)} className="text-brand-600 text-xs font-bold hover:underline">Répondre au client</button>
)}
</div>
)}
{rating.reply && (
<div className="mt-4 ml-13 bg-brand-50/50 p-4 rounded-lg border-l-2 border-brand-300">
<div className="flex items-center gap-2 mb-1">
<MessageCircle className="w-3 h-3 text-brand-600" />
<span className="text-[10px] font-bold text-brand-700 uppercase">Réponse de {business.name}</span>
</div>
<p className="text-gray-700 text-sm italic">{rating.reply}</p>
</div>
)}
</div>
))
) : (
<div className="text-center py-10 bg-gray-50 rounded-xl border border-dashed border-gray-200">
<Star className="w-10 h-10 text-gray-200 mx-auto mb-2" />
<p className="text-gray-500 text-sm">Soyez le premier à donner votre avis !</p>
</div>
)}
</div>
</div>
</div>
<div className="space-y-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Coordonnées</h2>
<div className="space-y-4">
{business.websiteUrl && (
<a href={business.websiteUrl.startsWith('http') ? business.websiteUrl : `https://${business.websiteUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
<Globe className="w-5 h-5 text-brand-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Site Web</p>
<p className="text-sm font-medium truncate">{business.websiteUrl.replace(/^https?:\/\//, '')}</p>
</div>
</a>
)}
{business.showEmail && business.contactEmail && (
<a href={`mailto:${business.contactEmail}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
<Mail className="w-5 h-5 text-brand-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Email</p>
<p className="text-sm font-medium truncate">{business.contactEmail}</p>
</div>
</a>
)}
{business.showPhone && business.contactPhone && (
<a href={`tel:${business.contactPhone}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
<Phone className="w-5 h-5 text-brand-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Téléphone</p>
<p className="text-sm font-medium truncate">{business.contactPhone}</p>
</div>
</a>
)}
{business.showSocials && business.socialLinks && (
<div className="pt-4 border-t border-gray-50">
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-3">Réseaux Sociaux</p>
<div className="flex gap-2">
{business.socialLinks.facebook && (
<a href={business.socialLinks.facebook} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-all">
<Facebook className="w-5 h-5" />
</a>
)}
{business.socialLinks.linkedin && (
<a href={business.socialLinks.linkedin} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-700 hover:bg-blue-50 transition-all">
<Linkedin className="w-5 h-5" />
</a>
)}
{business.socialLinks.instagram && (
<a href={business.socialLinks.instagram} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-pink-600 hover:bg-pink-50 transition-all">
<Instagram className="w-5 h-5" />
</a>
)}
{business.socialLinks.twitter && (
<a href={business.socialLinks.twitter} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-black hover:bg-gray-100 transition-all">
<Twitter className="w-5 h-5" />
</a>
)}
</div>
</div>
)}
</div>
</div>
<div id="contact-form" ref={contactRef} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Contacter l'entreprise</h2>
<form onSubmit={handleContactSubmit} className="space-y-4">
<textarea
id="contact-message"
value={contactForm.message}
onChange={(e) => setContactForm({...contactForm, message: e.target.value})}
placeholder="Votre message..."
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm focus:ring-2 focus:ring-brand-500 outline-none h-32"
required
/>
<button type="submit" disabled={formStatus === 'sending'} className="w-full bg-brand-600 text-white py-3 rounded-lg font-bold hover:bg-brand-700 transition-colors flex items-center justify-center gap-2">
{formStatus === 'sending' ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
Envoyer le message
</button>
</form>
</div>
</div>
</div>
</div>
</div>
);
};
export default BusinessDetailClient;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import { generateCsrfToken } from '@/lib/csrf';
export async function GET() {
const token = await generateCsrfToken();
return NextResponse.json({ csrfToken: token });
}

View File

@@ -0,0 +1,89 @@
import { NextResponse, NextRequest } from 'next/server';
import prisma from '@/lib/prisma';
import { sendEmail } from '@/lib/mail';
import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(req: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(req);
const isCsrfValid = await verifyCsrfToken(req);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { email } = await req.json();
if (!email) {
return NextResponse.json({ error: 'Email est requis' }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
// Pour des raisons de sécurité, on ne dit pas si l'utilisateur existe ou non
return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' });
}
// Générer un token
const token = crypto.randomBytes(32).toString('hex');
const expiry = new Date(Date.now() + 3600000); // 1 heure
await prisma.user.update({
where: { id: user.id },
data: {
resetToken: token,
resetTokenExpiry: expiry,
},
});
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
// Get site settings for email template
const settings = await prisma.siteSetting.findUnique({
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const subject = settings?.resetPasswordSubject || 'Réinitialisation de votre mot de passe - Afrohub';
let html = settings?.resetPasswordTemplate || `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
<p>Bonjour ${user.name},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
</div>
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 ${siteName}. Tous droits réservés.</p>
</div>
`;
// Replace placeholders
html = html
.replace(/{name}/g, user.name)
.replace(/{resetUrl}/g, resetUrl)
.replace(/{siteName}/g, siteName);
await sendEmail({
to: email,
subject,
html,
});
return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' });
} catch (error: any) {
console.error('Error in forgot-password:', error);
return NextResponse.json({ error: 'Une erreur est survenue lors de la demande' }, { status: 500 });
}
}

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
const isCsrfValid = await verifyCsrfToken(request);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { email } = await request.json();
if (!email) {
return NextResponse.json(
{ error: 'Email requis' },
{ status: 400 }
);
}
// Find user
const user = await prisma.user.findUnique({
where: { email }
});
if (!user) {
// For security, same message even if user doesn't exist
return NextResponse.json(
{ message: 'Si un compte existe pour cet email et n\'est pas encore vérifié, un nouvel email de vérification a été envoyé.' },
{ status: 200 }
);
}
if (user.emailVerified) {
return NextResponse.json(
{ error: 'Ce compte est déjà vérifié.' },
{ status: 400 }
);
}
// Generate new verification token
const verificationToken = crypto.randomBytes(32).toString('hex');
// Update user with new token
await prisma.user.update({
where: { id: user.id },
data: { verificationToken }
});
// Send verification email
try {
const settings = await prisma.siteSetting.findUnique({
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
let html = settings?.verifyEmailTemplate || `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
<p>Bonjour {name},</p>
<p>Vous avez demandé le renvoi de l'email de vérification pour votre compte sur {siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
</div>
</div>
`;
// Replace placeholders
html = html
.replace(/{name}/g, user.name)
.replace(/{verifyUrl}/g, verifyUrl)
.replace(/{siteName}/g, siteName);
const { sendEmail } = await import('@/lib/mail');
await sendEmail({
to: email,
subject,
html,
});
} catch (mailError) {
console.error('Failed to resend verification email:', mailError);
return NextResponse.json(
{ error: 'Erreur lors de l\'envoi de l\'email' },
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'Un nouvel email de vérification a été envoyé.' },
{ status: 200 }
);
} catch (error) {
console.error('Resend verification error:', error);
return NextResponse.json(
{ error: 'Une erreur est survenue' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,54 @@
import { NextResponse, NextRequest } from 'next/server';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
const isCsrfValid = await verifyCsrfToken(request);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { token, password } = await request.json();
if (!token || !password) {
return NextResponse.json({ error: 'Token et mot de passe sont requis' }, { status: 400 });
}
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: {
gt: new Date(),
},
},
});
if (!user) {
return NextResponse.json({ error: 'Token invalide ou expiré' }, { status: 400 });
}
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null,
},
});
return NextResponse.json({ message: 'Votre mot de passe a été mis à jour avec succès' });
} catch (error: any) {
console.error('Error in reset-password:', error);
return NextResponse.json({ error: 'Une erreur est survenue lors de la réinitialisation' }, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const token = searchParams.get('token');
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
if (!token) {
return NextResponse.redirect(new URL('/login?error=Token manquant', baseUrl));
}
const user = await prisma.user.findUnique({
where: { verificationToken: token }
});
if (!user) {
return NextResponse.redirect(new URL('/login?error=Token invalide ou expiré', baseUrl));
}
await prisma.user.update({
where: { id: user.id },
data: {
emailVerified: true,
verificationToken: null
}
});
return NextResponse.redirect(new URL('/login?verified=true', baseUrl));
} catch (error) {
console.error('Verification error:', error);
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
return NextResponse.redirect(new URL('/login?error=Erreur de vérification', baseUrl));
}
}

View File

@@ -27,6 +27,10 @@ export async function PATCH(
try { 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) {

View File

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

View File

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

View File

@@ -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,6 +106,8 @@ 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 || "",
@@ -123,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
@@ -132,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

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