Compare commits

...

143 Commits

Author SHA1 Message Date
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
33ec11d6db maj countries + cover annuaire details
Some checks failed
Build and Push App / build (push) Failing after 53m38s
2026-04-23 13:23:03 +02:00
12e48a738c ajout de mis en oeuvre secu 2026-04-23 10:22:01 +02:00
0460a1586f feat: add analytics dashboard and registration page with updated Next.js routing configuration
Some checks failed
Build and Push App / build (push) Failing after 22m29s
2026-04-22 23:07:45 +02:00
2e7cb65a29 feat: initialize Prisma schema and scaffold core application modules including auth, blogs, events, and business directories
Some checks failed
Build and Push App / build (push) Failing after 47s
2026-04-22 22:59:12 +02:00
b01b2f6476 Force l'installation des deps admin avant le build
All checks were successful
Build and Push App / build (push) Successful in 8m44s
2026-04-20 19:07:35 +02:00
d1d17de342 Ajout de react-quill-new dans les dépendances de l'admin
Some checks failed
Build and Push App / build (push) Failing after 51s
2026-04-20 18:06:26 +02:00
f4bd78ac44 maj pour le CICD et deploiement pour le build admin plus supp fichier env
Some checks failed
Build and Push App / build (push) Failing after 57s
2026-04-20 14:57:46 +02:00
68c396ea9a Fix dependencies pour build Dokploy
All checks were successful
Build and Push App / build (push) Successful in 2m56s
2026-04-20 12:24:11 +02:00
144bf2133f Downgrade Prisma vers 7.6.0 pour passer le build Dokploy
All checks were successful
Build and Push App / build (push) Successful in 2m57s
2026-04-20 12:05:58 +02:00
654a33c3a9 nixpack version 22.13.1
All checks were successful
Build and Push App / build (push) Successful in 5m53s
2026-04-20 11:53:08 +02:00
c26315bfdc retrait engine
Some checks failed
Build and Push App / build (push) Has been cancelled
2026-04-20 11:50:24 +02:00
bf1e118de3 maj nix nodejs24
All checks were successful
Build and Push App / build (push) Successful in 6m13s
2026-04-20 11:28:50 +02:00
40da981642 correction nixtpacks.toml
All checks were successful
Build and Push App / build (push) Successful in 6m28s
2026-04-20 11:19:12 +02:00
040a6a0ce4 ajout node version package.json
Some checks failed
Build and Push App / build (push) Has been cancelled
2026-04-20 11:17:29 +02:00
dd047c5aa1 maj deploy V4
All checks were successful
Build and Push App / build (push) Successful in 3m3s
2026-04-20 11:11:42 +02:00
cb5cee44f4 feat: update root nixpacks config and add deployment configuration for admin service
All checks were successful
Build and Push App / build (push) Successful in 6m28s
2026-04-19 22:19:08 +02:00
5ba6feeb99 build: upgrade to Node.js 23 and add nixpacks configuration for admin service
Some checks failed
Build and Push App / build (push) Has been cancelled
2026-04-19 22:14:35 +02:00
098c38f921 feat: add nixpacks configuration for admin service and update root build dependencies
Some checks failed
Build and Push App / build (push) Has been cancelled
2026-04-19 22:11:43 +02:00
bcbb004d87 feat: initialize admin service with nixpacks configuration and move dotenv to production dependencies
All checks were successful
Build and Push App / build (push) Successful in 5m49s
2026-04-19 22:05:27 +02:00
3958f84e03 dokploy maj
Some checks failed
Build and Push App / build (push) Has been cancelled
2026-04-19 22:01:35 +02:00
f97399e4fd correction nextjs user
All checks were successful
Build and Push App / build (push) Successful in 5m39s
2026-04-19 12:56:51 +02:00
270c4055dc correction deploiement v3
All checks were successful
Build and Push App / build (push) Successful in 4m35s
2026-04-19 12:31:19 +02:00
ce5245299c retour au base
All checks were successful
Build and Push App / build (push) Successful in 4m24s
2026-04-19 10:27:36 +02:00
c47d8821f6 correction gem high dockerfile prisma schema
All checks were successful
Build and Push App / build (push) Successful in 52s
2026-04-19 10:06:57 +02:00
46175b32f5 feat: initialize Prisma schema with core domain models for users, businesses, and platform features
Some checks failed
Build and Push App / build (push) Failing after 6s
2026-04-19 09:42:27 +02:00
0a5e2b4cdc correction du dockerfile pour deploiement
All checks were successful
Build and Push App / build (push) Successful in 1m51s
2026-04-19 09:14:54 +02:00
a28a75bc48 correction deploiement
All checks were successful
Build and Push App / build (push) Successful in 57s
2026-04-18 23:08:45 +02:00
69d0ef08e9 correction dockerfile
All checks were successful
Build and Push App / build (push) Successful in 32s
2026-04-18 22:59:46 +02:00
afb1853c59 correection layout build
All checks were successful
Build and Push App / build (push) Successful in 53s
2026-04-18 22:44:55 +02:00
f1ea12792a correction admin moderation
Some checks failed
Build and Push App / build (push) Failing after 47s
2026-04-18 22:41:52 +02:00
f1462b909c correction prisma legaldoc
Some checks failed
Build and Push App / build (push) Failing after 48s
2026-04-18 22:35:35 +02:00
3fab15a93c correction deploiement
Some checks failed
Build and Push App / build (push) Failing after 1m44s
2026-04-18 22:32:05 +02:00
3e2063e4fa feat: implement core platform features including business directory, admin dashboard, authentication, and API infrastructure
Some checks failed
Build and Push App / build (push) Failing after 16m2s
2026-04-18 22:10:19 +02:00
6ec1a3ae84 maj dockerfile
All checks were successful
Build and Push App / build (push) Successful in 1m54s
2026-04-15 23:54:06 +02:00
57ac75377b fix: use standalone prisma.config.js to avoid runtime module errors
All checks were successful
Build and Push App / build (push) Successful in 52s
2026-04-15 23:23:53 +02:00
d33ae2469f maj dockerfile packagejson
All checks were successful
Build and Push App / build (push) Successful in 1m2s
2026-04-15 23:08:56 +02:00
b2aad10734 fix: prisma 7 configuration and docker runtime copy
All checks were successful
Build and Push App / build (push) Successful in 30s
2026-04-15 23:00:34 +02:00
fab7b88882 fix: add missing public directory for docker build
All checks were successful
Build and Push App / build (push) Successful in 35s
2026-04-15 22:45:09 +02:00
93a4c1df5e maj x build
Some checks failed
Build and Push App / build (push) Failing after 22s
2026-04-15 22:43:32 +02:00
889e70bfc0 fix build
Some checks failed
Build and Push App / build (push) Failing after 21s
2026-04-15 22:40:04 +02:00
a38c5d1638 Fix missing ShieldAlert import in dashboard
Some checks failed
Build and Push App / build (push) Failing after 21s
2026-04-15 22:25:09 +02:00
cadbc5afe1 maj tsconfig
Some checks failed
Build and Push App / build (push) Failing after 20s
2026-04-15 22:23:32 +02:00
ef04b5a093 Use explicit context object for route handler
Some checks failed
Build and Push App / build (push) Failing after 22s
2026-04-15 22:20:04 +02:00
fae94ed8b1 fix build
Some checks failed
Build and Push App / build (push) Failing after 21s
2026-04-15 22:18:13 +02:00
d8a6d43ae1 correction route param
Some checks failed
Build and Push App / build (push) Failing after 22s
2026-04-15 22:15:42 +02:00
09ecf8a6b9 correction prisma build
Some checks failed
Build and Push App / build (push) Failing after 22s
2026-04-15 22:11:51 +02:00
dd4b35690c Actualiser .gitea/workflows/deploy.yaml
Some checks failed
Build and Push App / build (push) Failing after 34s
2026-04-15 20:06:26 +00:00
18954d0ef3 Actualiser .gitea/workflows/deploy.yaml
Some checks failed
Build and Push App / build (push) Failing after 10s
2026-04-15 20:00:46 +00:00
9917e19409 Actualiser .gitea/workflows/deploy.yaml
Some checks failed
Build and Push App / build (push) Failing after 3s
2026-04-15 19:58:06 +00:00
b1846f558b Actualiser .gitea/workflows/deploy.yaml
Some checks failed
Deploy Afropreunariat / build-and-push (push) Failing after 8s
2026-04-15 19:56:25 +00:00
803ff43909 maj deploy
Some checks failed
Deploy Afropreunariat / build-and-push (push) Failing after 2m44s
2026-04-15 20:56:31 +02:00
3cb07b8478 git actions 2026-04-15 20:52:47 +02:00
daac641e53 Maj dashboard, boutique + ajout de produit 2026-04-15 20:35:23 +02:00
fb5528337b maj x pour cicd 2026-04-13 08:37:29 +02:00
15644403de maj schema prisma 2026-04-13 08:33:14 +02:00
7a648e8182 maj v2 dockefile correction cicd 2026-04-13 08:29:58 +02:00
c543e7e8fb correction dockerfile vps 2026-04-13 08:27:25 +02:00
98010643e2 correction alpine linux dockerfile 2026-04-13 08:21:05 +02:00
41373e97f3 correction du dockerfile pour le cicd 2026-04-13 08:19:29 +02:00
c26c3b9485 maj 2026-04-12 22:51:57 +02:00
a5ccf2047b maj 2026-04-12 22:51:29 +02:00
887030ee47 synchronisation du contenu avec la DB et amélioration du CMS
- Migration du blog et des interviews des données mockées vers PostgreSQL (Prisma).
- Refonte des pages publiques en Server Components pour de meilleures performances/SEO.
- Intégration d'un éditeur de texte riche (React Quill) avec titres H1-H6 et séparateurs.
- Correction des erreurs de validation Prisma liées aux paramètres asynchrones (Next.js 15+).
- Correction des problèmes d'affichage des modales de suspension via React Portals.
- Installation de @tailwindcss/typography et correction du débordement de texte (break-words).
- Implémentation des actions de modération (suspension/révocation) pour les utilisateurs et boutiques.
2026-04-12 18:59:20 +02:00
b73939d381 . 2026-04-11 23:30:14 +02:00
10ca8c3e8c Ajout shortid
Ajout notification toast
maj db prisma
2026-04-11 23:29:50 +02:00
b227657f19 1. Système d'Analytics (Nouveauté Majeure)
Modèle de Données : Ajout de la table AnalyticsEvent dans prisma/schema.prisma pour suivre les vues de pages, les clics et le temps de rétention (DWELL_TIME).
API Backend : Création de la route app/api/analytics/route.ts pour enregistrer les événements en base de données.
Tracking Client : Création du composant AnalyticsTracker.tsx (intégré dans le layout principal) qui capture automatiquement :
Le changement de page (Page Views).
Les clics sur les boutons et liens importants.
Le temps passé sur chaque page.
2. Intégration Base de Données (PostgreSQL/Prisma)
Fiches Entreprises : Mise à jour de app/directory/[id]/page.tsx pour récupérer les données réelles depuis PostgreSQL via Prisma au lieu d'utiliser les données de test (mockData).
Navigation Dashboard : Correction du bouton "Voir ma fiche" dans le tableau de bord pour qu'il redirige correctement vers le profil public de l'entrepreneur.
3. Administration & Metrics
Nouvelle Application Admin : Initialisation d'un dossier admin/ (application Next.js séparée) destinée à la gestion de la plateforme et à la visualisation des statistiques (Metrics & Analytics).
4. Authentification & Inscription
Nouvelle Page : Création de app/register/page.tsx pour permettre l'inscription des nouveaux utilisateurs.
Gestion de Session : Amélioration de UserProvider.tsx pour une meilleure gestion de l'état utilisateur à travers l'application.
5. Maintenance & Types
Mise à jour des types globaux dans types.ts et rafraîchissement du package-lock.json suite à l'ajout de dépendances.
2026-04-11 22:20:41 +02:00
d88ba7c53c save avant prochaine modif 2026-04-08 14:46:10 +02:00
278 changed files with 32658 additions and 3079 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
.next
.git
.gitea
*.log
npm-debug.log*
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
dist
build

8
.env Normal file
View File

@@ -0,0 +1,8 @@
# Connexion locale via le Docker Compose fourni :
#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)
# 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

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Configuration Base de données (Dokploy injection)
DATABASE_URL="postgresql://user:password@host:port/dbname?schema=public"
# Configuration Application
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
# Autres variables (ex: Google AI, etc.)
# GOOGLE_GENERATIVE_AI_API_KEY="votre_cle_ici"
# NEXTAUTH_SECRET="votre_secret_ici"

5
.gitignore vendored
View File

@@ -6,11 +6,14 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.next
.next/*
node_modules
dist
dist-ssr
*.local
.env
.env.local
# Editor directories and files
.vscode/*

60
App.tsx
View File

@@ -1,60 +0,0 @@
import React, { useState } from 'react';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { User } from './types';
import { MOCK_USER } from './services/mockData';
// Layout
import Navbar from './components/Navbar';
import Footer from './components/Footer';
// Pages
import HomePage from './pages/HomePage';
import DirectoryPage from './pages/DirectoryPage';
import BlogPage from './pages/BlogPage';
import BlogPostPage from './pages/BlogPostPage';
import BusinessDetailPage from './pages/BusinessDetailPage';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import AfroLifePage from './pages/AfroLifePage';
import InterviewDetailPage from './pages/InterviewDetailPage';
import SubscriptionPage from './pages/SubscriptionPage';
const App = () => {
const [user, setUser] = useState<User | null>(null);
const handleLogin = () => {
setUser(MOCK_USER);
};
const handleLogout = () => {
setUser(null);
// Redirect handled by rendering logic
window.location.hash = '/';
};
return (
<HashRouter>
<div className="min-h-screen flex flex-col bg-gray-50 font-sans">
<Navbar user={user} onLogout={handleLogout} />
<main className="flex-grow">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/directory" element={<DirectoryPage />} />
<Route path="/directory/:id" element={<BusinessDetailPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/:id" element={<BlogPostPage />} />
<Route path="/afrolife" element={<AfroLifePage />} />
<Route path="/afrolife/:id" element={<InterviewDetailPage />} />
<Route path="/subscription" element={<SubscriptionPage />} />
<Route path="/login" element={<LoginPage onLogin={handleLogin} />} />
<Route path="/dashboard" element={user ? <DashboardPage user={user} onLogout={handleLogout} /> : <LoginPage onLogin={handleLogin} />} />
</Routes>
</main>
<Footer />
</div>
</HashRouter>
);
};
export default App;

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:22-alpine
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
# On copie tout, on installe tout, on build tout au même endroit
COPY . .
RUN npm ci
RUN npx prisma generate
RUN npm run build
EXPOSE 3000
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN chown -R nextjs:nodejs /app
USER nextjs
# On lance directement via npm
CMD ["npm", "start"]

41
admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
admin/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
admin/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

22
admin/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:22-alpine
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
# Copie et installation
COPY . .
RUN npm install
RUN npx prisma generate
RUN npm run build
EXPOSE 3001
ENV PORT=3001
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN chown -R nextjs:nodejs /app
USER nextjs
# On lance directement via npm (avec push de la BD si besoin, ou juste start)
CMD ["sh", "-c", "npx prisma db push --schema=prisma/schema.prisma --accept-data-loss && npm start"]

36
admin/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

12
admin/check-koliko.ts Normal file
View File

@@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const business = await prisma.business.findFirst({
where: { name: { contains: 'Koliko', mode: 'insensitive' } },
include: { owner: true }
});
console.log(JSON.stringify(business, null, 2));
}
main().catch(console.error).finally(() => prisma.$disconnect());

18
admin/eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

12
admin/next.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
import path from "path";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
turbopack: {
root: path.join(__dirname, ".."),
},
};
export default nextConfig;

13
admin/nixpacks.toml Normal file
View File

@@ -0,0 +1,13 @@
[phases.setup]
# Ce hash force Nixpacks à utiliser la version contenant Node 22.13.1
nixpkgsArchive = "51ad838b03a05b1de6f9f2a0fffecee64a9788ee"
nixPkgs = ["nodejs_22", "openssl"]
[phases.install]
cmds = ["npm ci"]
[phases.build]
cmds = ["npx prisma generate", "npm run build"]
[start]
cmd = "npx prisma db push --accept-data-loss && npm start"

7337
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
admin/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start -p 3000",
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-pg": "6.19.3",
"@prisma/client": "6.19.3",
"@prisma/config": "6.19.3",
"@types/bcryptjs": "^2.4.6",
"afrohub": "file:..",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.1",
"lucide-react": "^1.8.0",
"next": "16.2.3",
"pg": "^8.20.0",
"prisma": "6.19.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hot-toast": "^2.6.0",
"react-quill-new": "^3.8.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
admin/postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

View File

@@ -0,0 +1,459 @@
-- CreateEnum
CREATE TYPE "RatingStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('PENDING', 'RESOLVED', 'DISMISSED');
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('VISITOR', 'ENTREPRENEUR', 'ADMIN');
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('STARTER', 'BOOSTER', 'EMPIRE');
-- CreateEnum
CREATE TYPE "OfferType" AS ENUM ('PRODUCT', 'SERVICE');
-- CreateEnum
CREATE TYPE "InterviewType" AS ENUM ('VIDEO', 'ARTICLE');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'VISITOR',
"avatar" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"bio" TEXT,
"location" TEXT,
"phone" TEXT,
"isSuspended" BOOLEAN NOT NULL DEFAULT false,
"suspensionReason" TEXT,
"countryId" TEXT,
"resetToken" TEXT,
"resetTokenExpiry" TIMESTAMP(3),
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"verificationToken" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Business" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"location" TEXT NOT NULL,
"description" TEXT NOT NULL,
"logoUrl" TEXT NOT NULL,
"videoUrl" TEXT,
"contactEmail" TEXT NOT NULL,
"contactPhone" TEXT,
"socialLinks" JSONB,
"verified" BOOLEAN NOT NULL DEFAULT false,
"viewCount" INTEGER NOT NULL DEFAULT 0,
"rating" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"tags" TEXT[],
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"founderName" TEXT,
"founderImageUrl" TEXT,
"keyMetric" TEXT,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"slug" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT false,
"showEmail" BOOLEAN NOT NULL DEFAULT true,
"showPhone" BOOLEAN NOT NULL DEFAULT true,
"showSocials" BOOLEAN NOT NULL DEFAULT true,
"websiteUrl" TEXT,
"isSuspended" BOOLEAN NOT NULL DEFAULT false,
"suspensionReason" TEXT,
"plan" "Plan" NOT NULL DEFAULT 'STARTER',
"city" TEXT,
"countryId" TEXT,
"coverUrl" TEXT,
"coverPosition" TEXT DEFAULT '50% 50%',
"coverZoom" DOUBLE PRECISION DEFAULT 1.0,
"suggestedCategory" TEXT,
"categoryId" TEXT,
CONSTRAINT "Business_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BusinessCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"icon" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BusinessCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Country" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"flag" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"description" TEXT,
CONSTRAINT "Country_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PricingPlan" (
"id" TEXT NOT NULL,
"tier" "Plan" NOT NULL,
"name" TEXT NOT NULL,
"priceXOF" TEXT NOT NULL,
"priceEUR" TEXT NOT NULL,
"description" TEXT NOT NULL,
"features" TEXT[],
"offerLimit" INTEGER NOT NULL DEFAULT 1,
"recommended" BOOLEAN NOT NULL DEFAULT false,
"color" TEXT NOT NULL DEFAULT 'gray',
"updatedAt" TIMESTAMP(3) NOT NULL,
"yearlyPriceEUR" TEXT,
"yearlyPriceXOF" TEXT,
CONSTRAINT "PricingPlan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Offer" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"type" "OfferType" NOT NULL,
"price" DOUBLE PRECISION NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'XOF',
"description" TEXT,
"imageUrl" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"businessId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Offer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BlogPost" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"excerpt" TEXT NOT NULL,
"content" TEXT NOT NULL,
"author" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"imageUrl" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"metaDescription" TEXT,
"metaTitle" TEXT,
"slug" TEXT,
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Interview" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"guestName" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"role" TEXT NOT NULL,
"type" "InterviewType" NOT NULL,
"thumbnailUrl" TEXT NOT NULL,
"videoUrl" TEXT,
"content" TEXT,
"excerpt" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"duration" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"metaDescription" TEXT,
"metaTitle" TEXT,
"slug" TEXT,
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
CONSTRAINT "Interview_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnalyticsEvent" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"path" TEXT NOT NULL,
"label" TEXT,
"value" DOUBLE PRECISION,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"browser" TEXT,
"city" TEXT,
"country" TEXT,
"device" TEXT,
"ip" TEXT,
"os" TEXT,
"referrer" TEXT,
"userId" TEXT,
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rating" (
"id" TEXT NOT NULL,
"value" INTEGER NOT NULL,
"userId" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"comment" TEXT,
"reply" TEXT,
"replyAt" TIMESTAMP(3),
"status" "RatingStatus" NOT NULL DEFAULT 'PENDING',
CONSTRAINT "Rating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Conversation" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Conversation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ConversationParticipant" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "ConversationParticipant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Message" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"read" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MessageReport" (
"id" TEXT NOT NULL,
"reason" TEXT,
"messageId" TEXT NOT NULL,
"reporterId" TEXT NOT NULL,
"status" "ReportStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MessageReport_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SiteSetting" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"siteName" TEXT NOT NULL DEFAULT 'Afrohub',
"siteSlogan" TEXT NOT NULL DEFAULT 'La plateforme de référence pour l''entrepreneuriat africain.',
"contactEmail" TEXT NOT NULL DEFAULT 'support@afrohub.com',
"contactPhone" TEXT DEFAULT '+225 00 00 00 00 00',
"address" TEXT DEFAULT 'Abidjan, Côte d''Ivoire',
"facebookUrl" TEXT,
"twitterUrl" TEXT,
"instagramUrl" TEXT,
"linkedinUrl" TEXT,
"footerText" TEXT DEFAULT '© 2025 Afrohub. Tous droits réservés.',
"updatedAt" TIMESTAMP(3) NOT NULL,
"homeBlogCount" INTEGER NOT NULL DEFAULT 3,
"homeBlogShow" BOOLEAN NOT NULL DEFAULT true,
"homeBlogSubtitle" TEXT NOT NULL DEFAULT 'Toutes les actualités de l''entrepreneuriat africain.',
"homeBlogTitle" TEXT NOT NULL DEFAULT 'Derniers Articles',
"homeBlogCategories" TEXT[] DEFAULT ARRAY[]::TEXT[],
"homeBlogIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"homeBlogSelection" TEXT NOT NULL DEFAULT 'latest',
"homeCategories" TEXT[] DEFAULT ARRAY['Technologie & IT', 'Agriculture & Agrobusiness', 'Mode & Textile', 'Cosmétique & Beauté']::TEXT[],
"resetPasswordSubject" TEXT NOT NULL DEFAULT 'Réinitialisation de votre mot de passe - Afrohub',
"resetPasswordTemplate" TEXT NOT NULL DEFAULT '<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;"><h2 style="color: #0d9488; text-align: center;">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style="text-align: center; margin: 30px 0;"><a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n''avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;"><p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 {siteName}. Tous droits réservés.</p></div>',
"verifyEmailSubject" TEXT NOT NULL DEFAULT 'Vérification de votre compte - Afrohub',
"verifyEmailTemplate" TEXT NOT NULL DEFAULT '<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;"><h2 style="color: #0d9488; text-align: center;">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style="text-align: center; margin: 30px 0;"><a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a></div><p>Si vous n''avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;"><p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 {siteName}. Tous droits réservés.</p></div>',
CONSTRAINT "SiteSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LegalDocument" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LegalDocument_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Event" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"location" TEXT NOT NULL,
"thumbnailUrl" TEXT NOT NULL,
"link" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"metaDescription" TEXT,
"metaTitle" TEXT,
"slug" TEXT,
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Favorite" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Favorite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_resetToken_key" ON "User"("resetToken");
-- CreateIndex
CREATE UNIQUE INDEX "User_verificationToken_key" ON "User"("verificationToken");
-- CreateIndex
CREATE UNIQUE INDEX "Business_ownerId_key" ON "Business"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "Business_slug_key" ON "Business"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "BusinessCategory_name_key" ON "BusinessCategory"("name");
-- CreateIndex
CREATE UNIQUE INDEX "BusinessCategory_slug_key" ON "BusinessCategory"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Country_name_key" ON "Country"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Country_code_key" ON "Country"("code");
-- CreateIndex
CREATE UNIQUE INDEX "PricingPlan_tier_key" ON "PricingPlan"("tier");
-- CreateIndex
CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Interview_slug_key" ON "Interview"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Rating_userId_businessId_key" ON "Rating"("userId", "businessId");
-- CreateIndex
CREATE UNIQUE INDEX "ConversationParticipant_userId_conversationId_key" ON "ConversationParticipant"("userId", "conversationId");
-- CreateIndex
CREATE UNIQUE INDEX "LegalDocument_type_key" ON "LegalDocument"("type");
-- CreateIndex
CREATE UNIQUE INDEX "Event_slug_key" ON "Event"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Favorite_userId_businessId_key" ON "Favorite"("userId", "businessId");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Business" ADD CONSTRAINT "Business_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "BusinessCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Business" ADD CONSTRAINT "Business_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Business" ADD CONSTRAINT "Business_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Offer" ADD CONSTRAINT "Offer_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConversationParticipant" ADD CONSTRAINT "ConversationParticipant_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConversationParticipant" ADD CONSTRAINT "ConversationParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MessageReport" ADD CONSTRAINT "MessageReport_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MessageReport" ADD CONSTRAINT "MessageReport_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "BlogPost" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "Interview" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,11 @@
-- CreateEnum
CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- AlterTable
ALTER TABLE "BlogPost" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
-- AlterTable
ALTER TABLE "Interview" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';

View File

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

418
admin/prisma/schema.prisma Normal file
View File

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

1
admin/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
admin/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
admin/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

1
admin/public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
admin/public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,98 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { generateSlug } from "@/lib/utils";
import { ContentStatus } from "@prisma/client";
export async function createBlogPost(data: {
title: string;
slug?: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);
const post = await prisma.blogPost.create({
data: {
...data,
slug,
tags: data.tags || [],
publishedAt: data.publishedAt || new Date(),
status: data.status || ContentStatus.PUBLISHED,
},
});
revalidatePath("/actualites");
revalidatePath("/");
return { success: true, id: post.id };
} catch (error) {
console.error("Failed to create blog post:", error);
return { success: false, error: "Erreur lors de la création de l'article" };
}
}
export async function updateBlogPost(id: string, data: {
title: string;
slug?: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);
const post = await prisma.blogPost.update({
where: { id },
data: {
...data,
slug,
tags: data.tags || [],
},
});
revalidatePath("/actualites");
revalidatePath("/");
return { success: true, id: post.id };
} catch (error) {
console.error("Failed to update blog post:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteBlogPost(id: string) {
try {
await prisma.blogPost.delete({
where: { id },
});
revalidatePath("/actualites");
revalidatePath("/");
return { success: true };
} catch (error) {
console.error("Failed to delete blog post:", error);
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

@@ -0,0 +1,101 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function toggleVerification(id: string, currentStatus: boolean) {
try {
await prisma.business.update({
where: { id },
data: { verified: !currentStatus },
});
revalidatePath("/entrepreneurs");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Failed to toggle verification:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function setFeaturedBusiness(id: string, data: {
founderName: string;
founderImageUrl: string;
keyMetric: string;
}) {
try {
// 1. Reset all others (we only want one entrepreneur of the month)
await prisma.business.updateMany({
where: { isFeatured: true },
data: { isFeatured: false },
});
// 2. Set the new one
await prisma.business.update({
where: { id },
data: {
isFeatured: true,
...data,
},
});
revalidatePath("/entrepreneurs");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Failed to set featured business:", error);
return { success: false, error: "Erreur lors de la mise à jour de l'Afroshine" };
}
}
export async function removeFeaturedBusiness(id: string) {
try {
await prisma.business.update({
where: { id },
data: { isFeatured: false },
});
revalidatePath("/entrepreneurs");
return { success: true };
} catch (error) {
console.error("Failed to remove featured status:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}
export async function getPendingVerificationCount() {
try {
return await prisma.business.count({
where: {
isActive: true,
verified: false,
isSuspended: false,
plan: {
in: ['BOOSTER', 'EMPIRE']
}
}
});
} catch (error) {
console.error("Failed to get pending count:", error);
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

@@ -0,0 +1,18 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function deleteComment(id: string) {
try {
await prisma.comment.delete({
where: { id },
});
revalidatePath("/comments");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Failed to delete comment:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,75 @@
"use server";
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getCountries() {
try {
return await prisma.country.findMany({
orderBy: { name: 'asc' }
});
} catch (error) {
console.error('Error fetching countries:', error);
return [];
}
}
export async function addCountry(data: { name: string, code: string, flag?: string, description?: string }) {
try {
const country = await prisma.country.create({
data: {
name: data.name,
code: data.code.toUpperCase(),
flag: data.flag || null,
description: data.description || null,
isActive: true
}
});
revalidatePath('/countries');
return { success: true, country };
} catch (error) {
console.error('Error adding country:', error);
return { success: false, error: 'Ce pays ou code existe déjà.' };
}
}
export async function deleteCountry(id: string) {
try {
await prisma.country.delete({
where: { id }
});
revalidatePath('/countries');
return { success: true };
} catch (error) {
console.error('Error deleting country:', error);
return { success: false, error: 'Erreur lors de la suppression.' };
}
}
export async function toggleCountryStatus(id: string, isActive: boolean) {
try {
await prisma.country.update({
where: { id },
data: { isActive }
});
revalidatePath('/countries');
return { success: true };
} catch (error) {
console.error('Error toggling country status:', error);
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

@@ -0,0 +1,89 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { generateSlug } from "@/lib/utils";
import { ContentStatus } from "@prisma/client";
export async function createEvent(data: {
title: string;
slug?: string;
description: string;
date: Date;
location: string;
thumbnailUrl: string;
link?: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);
const event = await prisma.event.create({
data: {
...data,
slug,
tags: data.tags || [],
publishedAt: data.publishedAt || new Date(),
status: data.status || ContentStatus.PUBLISHED,
},
});
revalidatePath("/blog");
revalidatePath("/afrolife");
return { success: true, data: event };
} catch (error) {
console.error("Failed to create event:", error);
return { success: false, error: "Erreur lors de la création de l'événement" };
}
}
export async function updateEvent(id: string, data: {
title: string;
slug?: string;
description: string;
date: Date;
location: string;
thumbnailUrl: string;
link?: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);
const event = await prisma.event.update({
where: { id },
data: {
...data,
slug,
tags: data.tags || [],
},
});
revalidatePath("/blog");
revalidatePath("/afrolife");
return { success: true, data: event };
} catch (error) {
console.error("Failed to update event:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteEvent(id: string) {
try {
await prisma.event.delete({
where: { id },
});
revalidatePath("/blog");
revalidatePath("/afrolife");
return { success: true };
} catch (error) {
console.error("Failed to delete event:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,94 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { InterviewType, ContentStatus } from "@prisma/client";
import { generateSlug } from "@/lib/utils";
export async function createInterview(data: {
title: string;
slug?: string;
guestName: string;
companyName: string;
role: string;
type: InterviewType;
thumbnailUrl: string;
videoUrl?: string;
content?: string;
excerpt: string;
duration?: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);
const interview = await prisma.interview.create({
data: {
...data,
slug,
tags: data.tags || [],
publishedAt: data.publishedAt || new Date(),
status: data.status || ContentStatus.PUBLISHED,
},
});
revalidatePath("/blog");
return { success: true, data: interview };
} catch (error) {
console.error("Failed to create interview:", error);
return { success: false, error: "Erreur lors de la création de l'interview" };
}
}
export async function updateInterview(id: string, data: {
title: string;
slug?: string;
guestName: string;
companyName: string;
role: string;
type: InterviewType;
thumbnailUrl: string;
videoUrl?: string;
content?: string;
excerpt: string;
duration?: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) {
try {
const slug = data.slug || generateSlug(data.title);
const interview = await prisma.interview.update({
where: { id },
data: {
...data,
slug,
tags: data.tags || [],
},
});
revalidatePath("/blog");
return { success: true, data: interview };
} catch (error) {
console.error("Failed to update interview:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteInterview(id: string) {
try {
await prisma.interview.delete({
where: { id },
});
revalidatePath("/blog");
return { success: true };
} catch (error) {
console.error("Failed to delete interview:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,31 @@
"use server";
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getLegalDocument(type: 'CGU' | 'CGV') {
try {
return await prisma.legalDocument.findUnique({
where: { type }
});
} catch (error) {
console.error(`Error fetching ${type}:`, error);
return null;
}
}
export async function updateLegalDocument(type: 'CGU' | 'CGV', title: string, content: string) {
try {
const doc = await prisma.legalDocument.upsert({
where: { type },
update: { title, content },
create: { type, title, content }
});
revalidatePath('/legal');
return { success: true, doc };
} catch (error) {
console.error(`Error updating ${type}:`, error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}

View File

@@ -0,0 +1,80 @@
"use server";
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getReports() {
return await prisma.messageReport.findMany({
include: {
message: {
include: {
sender: {
select: { id: true, name: true, email: true, isSuspended: true }
},
conversation: {
include: {
business: {
select: { id: true, name: true }
},
messages: {
orderBy: { createdAt: 'asc' },
include: {
sender: {
select: { id: true, name: true }
}
}
}
}
}
}
},
reporter: {
select: { id: true, name: true, email: true }
}
},
orderBy: {
createdAt: 'desc',
},
});
}
export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMISSED') {
try {
await prisma.messageReport.update({
where: { id },
data: { status }
});
revalidatePath('/moderation');
return { success: true };
} catch (error) {
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function getPendingRatings() {
return await prisma.rating.findMany({
where: { status: 'PENDING' },
include: {
user: {
select: { id: true, name: true, email: true }
},
business: {
select: { id: true, name: true }
}
},
orderBy: { createdAt: 'desc' }
});
}
export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJECTED') {
try {
await prisma.rating.update({
where: { id },
data: { status }
});
revalidatePath('/moderation');
return { success: true };
} catch (error) {
return { success: false, error: "Erreur lors de la mise à jour de l'avis" };
}
}

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

@@ -0,0 +1,56 @@
"use server";
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getPricingPlans() {
try {
return await prisma.pricingPlan.findMany({
orderBy: { offerLimit: 'asc' }
});
} catch (error) {
console.error('Error fetching pricing plans:', error);
return [];
}
}
export async function updatePricingPlanDetail(id: string, data: any) {
try {
await prisma.pricingPlan.update({
where: { id },
data: {
name: data.name,
priceXOF: data.priceXOF,
yearlyPriceXOF: data.yearlyPriceXOF || null,
priceEUR: data.priceEUR,
yearlyPriceEUR: data.yearlyPriceEUR || null,
description: data.description,
features: data.features, // Expected to be string[]
offerLimit: parseInt(data.offerLimit),
recommended: !!data.recommended,
color: data.color
}
});
revalidatePath('/plans');
return { success: true };
} catch (error) {
console.error('Error updating pricing plan detail:', error);
return { success: false, error: 'Erreur lors de la mise à jour des détails du forfait.' };
}
}
export async function updateBusinessPlan(id: string, plan: 'STARTER' | 'BOOSTER' | 'EMPIRE') {
try {
await prisma.business.update({
where: { id },
data: { plan }
});
revalidatePath('/entrepreneurs');
return { success: true };
} catch (error) {
console.error('Error updating business plan:', error);
return { success: false, error: 'Erreur lors de la mise à jour du forfait.' };
}
}

View File

@@ -0,0 +1,85 @@
"use server";
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getSiteSettings() {
try {
const settings = await prisma.siteSetting.findUnique({
where: { id: 'singleton' }
});
// Default fallback values if not initialized
const defaultSettings = {
siteName: "Afrohub",
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain.",
contactEmail: "support@afrohub.com",
contactPhone: "+225 00 00 00 00 00",
address: "Abidjan, Côte d'Ivoire",
facebookUrl: "",
twitterUrl: "",
instagramUrl: "",
linkedinUrl: "",
footerText: "© 2025 Afrohub. Tous droits réservés.",
homeBlogShow: true,
homeBlogTitle: "Derniers Articles",
homeBlogSubtitle: "Toutes les actualités de l'entrepreneuriat africain.",
homeBlogCount: 3,
homeBlogSelection: "latest",
homeBlogIds: [],
homeBlogCategories: [],
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"],
resetPasswordSubject: "Réinitialisation de votre mot de passe - Afrohub",
resetPasswordTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
<p>Bonjour {name},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
</div>
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="font-size: 12px; color: #6b7280; text-align: center;">&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;
return settings;
} catch (error) {
console.error("Failed to fetch settings:", error);
return null;
}
}
export async function updateSiteSettings(data: any) {
try {
const settings = await prisma.siteSetting.upsert({
where: { id: 'singleton' },
update: data,
create: {
id: 'singleton',
...data
}
});
revalidatePath('/settings');
revalidatePath('/', 'layout');
return { success: true, settings };
} catch (error) {
console.error("Failed to update settings:", error);
return { success: false, error: "Erreur lors de la mise à jour des paramètres" };
}
}

View File

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

View File

@@ -0,0 +1,125 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
/**
* Suspend a user account
*/
export async function suspendUser(userId: string, reason: string) {
try {
const user = await prisma.user.update({
where: { id: userId },
data: {
isSuspended: true,
suspensionReason: reason,
},
include: {
businesses: true
}
});
// If the user is an entrepreneur with a business, we also suspend the business by default
// to hide it from the site during the user's suspension.
if (user.businesses) {
await prisma.business.update({
where: { id: user.businesses.id },
data: {
isSuspended: true,
suspensionReason: `Compte propriétaire suspendu : ${reason}`,
},
});
}
revalidatePath("/clients");
revalidatePath("/entrepreneurs");
revalidatePath("/moderation");
return { success: true, data: user };
} catch (error) {
console.error("Failed to suspend user:", error);
return { success: false, error: "Erreur lors de la suspension" };
}
}
/**
* Unsuspend a user account
*/
export async function unsuspendUser(userId: string) {
try {
const user = await prisma.user.update({
where: { id: userId },
data: {
isSuspended: false,
suspensionReason: null,
},
include: {
businesses: true
}
});
// Also unsuspend their business if they had one
if (user.businesses) {
await prisma.business.update({
where: { id: user.businesses.id },
data: {
isSuspended: false,
suspensionReason: null,
},
});
}
revalidatePath("/clients");
revalidatePath("/entrepreneurs");
revalidatePath("/moderation");
return { success: true, data: user };
} catch (error) {
console.error("Failed to unsuspend user:", error);
return { success: false, error: "Erreur lors de la réactivation" };
}
}
/**
* Suspend a specific business (without suspending the whole user account)
*/
export async function suspendBusiness(businessId: string, reason: string) {
try {
const business = await prisma.business.update({
where: { id: businessId },
data: {
isSuspended: true,
suspensionReason: reason,
},
});
revalidatePath("/entrepreneurs");
return { success: true, data: business };
} catch (error) {
console.error("Failed to suspend business:", error);
return { success: false, error: "Erreur lors de la suspension de la boutique" };
}
}
/**
* Unsuspend a specific business
*/
export async function unsuspendBusiness(businessId: string) {
try {
const business = await prisma.business.update({
where: { id: businessId },
data: {
isSuspended: false,
suspensionReason: null,
},
});
revalidatePath("/entrepreneurs");
return { success: true, data: business };
} catch (error) {
console.error("Failed to unsuspend business:", error);
return { success: false, error: "Erreur lors de la réactivation de la boutique" };
}
}

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,42 @@
'use server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
export async function uploadImage(formData: FormData) {
try {
const file = formData.get('file') as File;
if (!file) {
return { success: false, error: 'Aucun fichier fourni' };
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Create a unique filename
const fileExtension = file.name.split('.').pop();
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
// Path for the ROOT public/uploads (where the main site will look)
const rootPublicDir = join(process.cwd(), '..', 'public', 'uploads');
// Path for the ADMIN public/uploads (where the admin app can see it for preview)
const adminPublicDir = join(process.cwd(), 'public', 'uploads');
// Ensure both directories exist
await mkdir(rootPublicDir, { recursive: true });
await mkdir(adminPublicDir, { recursive: true });
// Save to both
await writeFile(join(rootPublicDir, fileName), buffer);
await writeFile(join(adminPublicDir, fileName), buffer);
// Return the relative URL
return {
success: true,
url: `/uploads/${fileName}`
};
} catch (error) {
console.error('Error during image upload:', error);
return { success: false, error: 'Erreur lors de l\'upload de l\'image' };
}
}

View File

@@ -0,0 +1,43 @@
"use server";
import { prisma } from "@/lib/prisma";
export async function getClients() {
try {
const clients = await prisma.user.findMany({
where: {
role: { not: "ADMIN" },
OR: [
{ businesses: null },
{ businesses: { isActive: false } }
]
},
include: {
businesses: true
},
orderBy: {
createdAt: "desc"
}
});
return { success: true, data: clients };
} catch (error) {
console.error("Failed to fetch clients:", error);
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" };
}
}

View File

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

View File

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

View File

@@ -0,0 +1,266 @@
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin, Clock, ShieldCheck, ShieldAlert } from 'lucide-react';
import DeleteActualitesButton from '@/components/DeleteActualitesButton';
import DeleteInterviewButton from '@/components/DeleteInterviewButton';
import DeleteEventButton from '@/components/DeleteEventButton';
import ApproveContentButton from '@/components/ApproveContentButton';
async function getData() {
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
const interviews = await prisma.interview.findMany({ orderBy: { createdAt: 'desc' } });
const events = await prisma.event.findMany({ orderBy: { date: 'desc' } });
return { posts, interviews, events };
}
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 pendingPosts = posts.filter(p => p.status === 'PENDING');
const pendingEvents = events.filter(e => e.status === 'PENDING');
const hasPending = pendingPosts.length > 0 || pendingEvents.length > 0;
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">CMS Actualités, Interviews & Événements</h1>
<p className="text-slate-400">Gérez le contenu éditorial et événementiel de la plateforme.</p>
</div>
<div className="flex flex-wrap gap-4">
<Link href="/actualites/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
<Plus className="w-4 h-4" />
Nouvel Article
</Link>
<Link href="/interview/new" className="btn-verify bg-emerald-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
Nouvelle Interview
</Link>
<Link href="/event/new" className="btn-verify bg-amber-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
Nouvel Événement
</Link>
</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">
{/* Blog Posts Section */}
<div className="card">
<div className="flex items-center gap-2 mb-6">
<BookOpen className="text-indigo-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Articles d'Actualités ({posts.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="data-table w-full">
<thead>
<tr>
<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">Date</th>
<th className="text-right py-3 px-4">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{posts.map((post) => (
<tr key={post.id} className="hover:bg-slate-800/30 transition-colors">
<td className="py-4 px-4">
<div className="font-medium text-white">{post.title}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
</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-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
<td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2">
<Link href={`/actualites/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
<Edit className="w-5 h-5" />
</Link>
<DeleteActualitesButton id={post.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Interviews Section */}
<div className="card">
<div className="flex items-center gap-2 mb-6">
<Mic2 className="text-emerald-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Interviews ({interviews.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="data-table w-full">
<thead>
<tr>
<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">Type</th>
<th className="text-right py-3 px-4">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{interviews.map((interview) => (
<tr key={interview.id} className="hover:bg-slate-800/30 transition-colors">
<td className="py-4 px-4">
<div className="font-medium text-white">{interview.guestName}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
</td>
<td className="py-4 px-4">{getStatusBadge(interview.status)}</td>
<td className="py-4 px-4">
<div className="text-sm text-slate-300">{interview.companyName}</div>
<div className="text-xs text-slate-500">{interview.role}</div>
</td>
<td className="py-4 px-4">
<span className={`px-2 py-1 rounded-full text-[10px] font-bold ${interview.type === 'VIDEO' ? 'bg-indigo-500/10 text-indigo-400' : 'bg-emerald-500/10 text-emerald-400'}`}>
{interview.type}
</span>
</td>
<td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2">
<Link href={`/interview/edit/${interview.id}`} className="p-2 text-slate-400 hover:text-emerald-400">
<Edit className="w-5 h-5" />
</Link>
<DeleteInterviewButton id={interview.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Events Section */}
<div className="card">
<div className="flex items-center gap-2 mb-6">
<Calendar className="text-amber-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Événements ({events.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="data-table w-full">
<thead>
<tr>
<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-right py-3 px-4">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{events.map((event) => (
<tr key={event.id} className="hover:bg-slate-800/30 transition-colors">
<td className="py-4 px-4">
<div className="font-medium text-white">{event.title}</div>
<div className="text-xs text-slate-500 truncate max-w-sm">
{event.link ? 'Lien activé' : 'Pas de lien'}
</div>
</td>
<td className="py-4 px-4">{getStatusBadge(event.status)}</td>
<td className="py-4 px-4">
<div className="flex items-center text-sm text-slate-300 gap-2">
<Calendar className="w-3 h-3 text-slate-500" />
{new Date(event.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</div>
<div className="flex items-center text-xs text-slate-500 gap-2 mt-1">
<MapPin className="w-3 h-3" />
{event.location}
</div>
</td>
<td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2">
<Link href={`/event/edit/${event.id}`} className="p-2 text-slate-400 hover:text-amber-400">
<Edit className="w-5 h-5" />
</Link>
<DeleteEventButton id={event.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { prisma } from '@/lib/prisma';
import DeleteCommentButton from '@/components/DeleteCommentButton';
async function getComments() {
return await prisma.comment.findMany({
include: {
author: true,
business: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
export default async function CommentsPage() {
const comments = await getComments();
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Modération des Commentaires</h1>
<p className="text-slate-400">Surveiller et supprimer les commentaires inappropriés.</p>
</div>
<div className="card overflow-hidden p-0">
<table className="data-table">
<thead>
<tr>
<th>Auteur</th>
<th>Entreprise</th>
<th>Contenu</th>
<th>Date</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody>
{comments.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-10 text-slate-500">
Aucun commentaire trouvé.
</td>
</tr>
) : (
comments.map((comment) => (
<tr key={comment.id}>
<td>
<div className="font-medium text-white">{comment.author.name}</div>
<div className="text-xs text-slate-500">{comment.author.email}</div>
</td>
<td>
<div className="text-sm text-indigo-400">{comment.business.name}</div>
</td>
<td className="max-w-md">
<p className="text-sm text-slate-300 truncate" title={comment.content}>
{comment.content}
</p>
</td>
<td>
<div className="text-sm text-slate-500">
{new Date(comment.createdAt).toLocaleDateString('fr-FR')}
</div>
</td>
<td className="text-right">
<DeleteCommentButton id={comment.id} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
"use client";
import React, { useState, useEffect, useTransition } from 'react';
import { getCountries, addCountry, deleteCountry, toggleCountryStatus, updateCountryDescription } from '@/app/actions/countries';
import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag, Edit2, Check, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function CountriesPage() {
const [countries, setCountries] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editDesc, setEditDesc] = useState("");
const [isPending, startTransition] = useTransition();
const loadData = async () => {
const data = await getCountries();
setCountries(data);
setLoading(false);
};
useEffect(() => {
loadData();
}, []);
const handleAdd = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get('name') as string;
const code = formData.get('code') as string;
const flag = formData.get('flag') as string;
const description = formData.get('description') as string;
startTransition(async () => {
const result = await addCountry({ name, code, flag, description });
if (result.success) {
toast.success("Pays ajouté avec succès");
setIsAdding(false);
loadData();
} else {
toast.error(result.error || "Erreur lors de l'ajout");
}
});
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Supprimer le pays "${name}" ? Cela pourrait affecter les entreprises liées.`)) return;
const result = await deleteCountry(id);
if (result.success) {
toast.success("Pays supprimé");
loadData();
} else {
toast.error("Erreur lors de la suppression");
}
};
const handleToggleStatus = async (id: string, current: boolean) => {
const result = await toggleCountryStatus(id, !current);
if (result.success) {
toast.success(!current ? "Pays activé" : "Pays désactivé");
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) {
return (
<div className="flex items-center justify-center p-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
</div>
);
}
return (
<div className="p-8">
<div className="flex justify-between items-end mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Pays</h1>
<p className="text-slate-400">Gérez les pays disponibles pour les entreprises et le filtrage du répertoire.</p>
</div>
<button
onClick={() => setIsAdding(!isAdding)}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-xl font-bold transition-all shadow-lg flex items-center gap-2"
>
<Plus className="w-5 h-5" /> Ajouter un pays
</button>
</div>
{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">
<h2 className="text-lg font-bold text-white mb-4">Nouveau Pays</h2>
<form onSubmit={handleAdd} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<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 className="space-y-2">
<label className="text-sm font-medium text-slate-400">Note / Description (interne)</label>
<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 className="flex gap-2 justify-end">
<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">
Annuler
</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>
</form>
</div>
)}
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-800/50 text-slate-400 uppercase text-xs font-bold tracking-wider">
<tr>
<th className="px-6 py-4">Pays</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 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{countries.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-10 text-center text-slate-500">Aucun pays configuré.</td>
</tr>
) : countries.map((country) => (
<tr key={country.id} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="text-2xl">{country.flag || <MapPin className="w-5 h-5 text-slate-600" />}</span>
<span className="font-bold text-white">{country.name}</span>
</div>
</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">
<button
onClick={() => handleToggleStatus(country.id, country.isActive)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
country.isActive ? 'bg-indigo-600' : 'bg-slate-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
country.isActive ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className={`ml-3 text-xs font-bold ${country.isActive ? 'text-emerald-500' : 'text-slate-500'}`}>
{country.isActive ? 'ACTIF' : 'INACTIF'}
</span>
</td>
<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">
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import React from 'react';
import {
Users,
Store,
Clock,
MessageCircle,
Eye
} from 'lucide-react';
interface Stats {
usersCount: number;
businessCount: number;
pendingCount: number;
commentsCount: number;
totalViews: number;
uniqueVisitors: number;
}
export default function DashboardClient({ stats }: { stats: Stats }) {
const statCards = [
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
];
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
</div>
<div className="stats-grid">
{statCards.map((card) => (
<div key={card.label} className="card">
<div className="flex justify-between items-start mb-4">
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
<card.icon className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">{card.value}</span>
</div>
<h3 className="text-slate-400 font-medium">{card.label}</h3>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { prisma } from '@/lib/prisma';
import DashboardClient from './DashboardClient';
async function getStats() {
const usersCount = await prisma.user.count();
const businessCount = await prisma.business.count();
const pendingCount = await prisma.business.count({ where: { verified: false } });
const commentsCount = await prisma.comment.count();
// Aggregate views
const aggregateResult = await prisma.business.aggregate({
_sum: {
viewCount: true
}
});
const totalViews = aggregateResult._sum.viewCount || 0;
// Unique Visitors (by IP)
const uniqueVisitorsRes = await prisma.analyticsEvent.groupBy({
by: ['ip'],
});
const uniqueVisitors = uniqueVisitorsRes.length;
return { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors };
}
export default async function DashboardPage() {
const stats = await getStats();
return <DashboardClient stats={stats} />;
}

View File

@@ -0,0 +1,20 @@
import EventForm from "@/components/EventForm";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditEventPage({ params }: Props) {
const { id } = await params;
const event = await prisma.event.findUnique({
where: { id },
});
if (!event) {
notFound();
}
return <EventForm initialData={event} />;
}

View File

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

BIN
admin/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

301
admin/src/app/globals.css Normal file
View File

@@ -0,0 +1,301 @@
@import "tailwindcss";
:root {
--background: #0f172a;
--foreground: #f8fafc;
--primary: #6366f1;
--secondary: #1e293b;
--accent: #10b981;
--muted: #64748b;
--border: #334155;
--card: #1e293b;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: 'Inter', sans-serif;
margin: 0;
padding: 0;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border);
}
.admin-sidebar {
width: 260px;
background-color: var(--card);
border-right: 1px solid var(--border);
height: 100vh;
position: fixed;
left: 0;
top: 0;
}
.admin-content {
margin-left: 260px;
padding: 2rem;
min-height: 100vh;
}
.nav-link {
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
color: var(--muted);
text-decoration: none;
transition: all 0.2s;
border-radius: 0.5rem;
margin: 0.25rem 1rem;
}
.nav-link:hover, .nav-link.active {
background-color: var(--secondary);
color: var(--foreground);
}
.nav-link.active {
color: var(--primary);
background-color: rgba(99, 102, 241, 0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.5rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.data-table th {
text-align: left;
padding: 1rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.btn-verify {
background-color: var(--accent);
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
.btn-verify:hover {
opacity: 0.9;
}
.btn-unverify {
background-color: #ef4444;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
.btn-unverify:hover {
opacity: 0.9;
}
.btn-action {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid transparent;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
transition: all 0.2s;
}
.btn-action:hover {
opacity: 1;
transform: translateY(-1px);
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-verified {
background-color: rgba(16, 185, 129, 0.1);
color: var(--accent);
}
.badge-pending {
background-color: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.badge-error {
background-color: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
/* Modals */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.modal-content {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 1rem;
width: 100%;
max-width: 500px;
padding: 2rem;
position: relative;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
}
.btn-star:hover, .btn-star.active {
color: #f59e0b;
}
/* Quill Dark Theme Overrides */
.quill-dark-wrapper .ql-toolbar.ql-snow {
background-color: var(--secondary);
border-color: var(--border);
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.quill-dark-wrapper .ql-container.ql-snow {
background-color: var(--background);
border-color: var(--border);
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
min-height: 300px;
font-family: inherit;
font-size: 1rem;
}
.quill-dark-wrapper .ql-snow .ql-stroke {
stroke: var(--foreground);
}
.quill-dark-wrapper .ql-snow .ql-fill {
fill: var(--foreground);
}
.quill-dark-wrapper .ql-snow .ql-picker {
color: var(--foreground);
}
.quill-dark-wrapper .ql-snow .ql-picker-options {
background-color: var(--secondary);
border-color: var(--border);
}
.quill-dark-wrapper .ql-snow .ql-picker-item:hover,
.quill-dark-wrapper .ql-snow .ql-picker-label:hover {
color: var(--primary);
}
.quill-dark-wrapper .ql-snow .ql-active {
color: var(--primary) !important;
}
.quill-dark-wrapper .ql-snow .ql-active .ql-stroke {
stroke: var(--primary) !important;
}
.quill-dark-wrapper .ql-editor.ql-blank::before {
color: var(--muted);
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) */
.quill-dark-wrapper .ql-editor hr {
border: none;
border-top: 2px solid var(--border);
margin: 1.5em 0;
cursor: default;
}
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider {
width: 32px;
position: relative;
}
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 2px;
background-color: var(--foreground);
}
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
background-color: var(--primary);
}

View File

@@ -0,0 +1,16 @@
import { prisma } from "@/lib/prisma";
import InterviewForm from "@/components/InterviewForm";
import { notFound } from "next/navigation";
export default async function EditInterviewPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const interview = await prisma.interview.findUnique({
where: { id },
});
if (!interview) {
notFound();
}
return <InterviewForm initialData={interview} />;
}

View File

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

33
admin/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
export const dynamic = 'force-dynamic';
import { Inter } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
import { Toaster } from 'react-hot-toast';
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AfroAdmin - Administration Afrohub",
description: "Panneau d'administration pour la plateforme Afrohub",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body className={inter.className}>
<Toaster position="bottom-right" />
<div className="flex min-h-screen">
<Sidebar />
<main className="admin-content flex-1">
{children}
</main>
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { getLegalDocument } from '@/app/actions/legal';
import LegalEditor from '@/components/LegalEditor';
export const metadata = {
title: 'Documents Légaux - AfroAdmin',
description: 'Gérez les CGU et CGV de la plateforme.',
};
export default async function LegalPage() {
const cgu = await getLegalDocument('CGU');
const cgv = await getLegalDocument('CGV');
return (
<div className="p-8 max-w-5xl mx-auto">
<div className="mb-10">
<h1 className="text-3xl font-bold text-white mb-2">Documents Légaux</h1>
<p className="text-slate-400">Modifiez les conditions générales d'utilisation et de vente du site.</p>
</div>
<LegalEditor initialCgu={cgu} initialCgv={cgv} />
</div>
);
}

View File

@@ -0,0 +1,449 @@
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
import {
BarChart3,
MousePointer2,
Clock,
Eye,
ArrowUpRight,
TrendingUp,
Globe,
Users,
Zap,
Search,
ArrowUp,
ArrowDown,
Calendar
} from 'lucide-react';
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
if (value === null) return null;
const isPositive = value > 0;
const isZero = Math.abs(value) < 0.1;
if (isZero) return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400">
= 0%
</span>
);
return (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold ${
isPositive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'
}`}>
{isPositive ? <ArrowUp className="w-2 h-2 mr-0.5" /> : <ArrowDown className="w-2 h-2 mr-0.5" />}
{Math.abs(value).toFixed(1)}{isPercent ? '%' : ''}
</span>
);
}
async function getMetrics(range: string = 'week', from?: string, to?: string) {
let startDate: Date;
let endDate: Date = new Date();
const now = new Date();
// 1. Determine Current Range
if (from && to) {
startDate = new Date(from);
endDate = new Date(to);
endDate.setHours(23, 59, 59, 999);
} else {
if (range === 'day') startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
else if (range === 'month') startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
else if (range === 'all') startDate = new Date(0); // All time
else startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // Default Week
}
const currentWhere = { createdAt: { gte: startDate, lte: endDate } };
// 2. Determine Previous Range (for comparison)
let prevWhere: any = null;
if (range !== 'all') {
const duration = endDate.getTime() - startDate.getTime();
const prevEndDate = new Date(startDate.getTime() - 1);
const prevStartDate = new Date(startDate.getTime() - duration);
prevWhere = { createdAt: { gte: prevStartDate, lte: prevEndDate } };
}
// 3. Fetch Current Data
const topPages = await prisma.analyticsEvent.groupBy({
by: ['path'],
where: { type: 'PAGE_VIEW', ...currentWhere },
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 10
});
const topCountries = await prisma.analyticsEvent.groupBy({
by: ['country'],
where: currentWhere,
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 10
});
const devices = await prisma.analyticsEvent.groupBy({
by: ['device'],
where: currentWhere,
_count: { id: true },
orderBy: { _count: { id: 'desc' } }
});
const browsers = await prisma.analyticsEvent.groupBy({
by: ['browser'],
where: currentWhere,
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 5
});
const totalEvents = await prisma.analyticsEvent.count({ where: currentWhere });
const uniqueVisitorsRes = await prisma.analyticsEvent.groupBy({
by: ['ip'],
where: currentWhere
});
const uniqueVisitors = uniqueVisitorsRes.length;
// 4. Fetch Previous Data (for Deltas)
let prevTotalEvents = 0;
let prevUniqueVisitors = 0;
let prevMobileCount = 0;
if (prevWhere) {
prevTotalEvents = await prisma.analyticsEvent.count({ where: prevWhere });
const prevVisitorsRes = await prisma.analyticsEvent.groupBy({
by: ['ip'],
where: prevWhere
});
prevUniqueVisitors = prevVisitorsRes.length;
const prevDevices = await prisma.analyticsEvent.groupBy({
by: ['device'],
where: prevWhere,
_count: { id: true }
});
prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0;
}
// 5. Recent Unique IPs
const recentUniqueIPs = await prisma.analyticsEvent.groupBy({
by: ['ip'],
where: currentWhere,
_max: { createdAt: true },
orderBy: { _max: { createdAt: 'desc' } },
take: 20
});
const recentIPs = await Promise.all(
recentUniqueIPs.map(async (item) => {
if (!item.ip || !item._max.createdAt) return null;
return await prisma.analyticsEvent.findFirst({
where: {
ip: item.ip,
createdAt: item._max.createdAt
}
});
})
);
const currentMobileCount = devices.find(d => d.device === 'Mobile')?._count.id || 0;
return {
topPages,
topCountries,
devices,
browsers,
totalEvents,
uniqueVisitors,
recentIPs,
deltas: {
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
mobile: prevTotalEvents > 0 && totalEvents > 0
? ((currentMobileCount / totalEvents) - (prevMobileCount / prevTotalEvents)) * 100
: null
}
};
}
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
const searchParams = await searchParamsPromise;
const range = searchParams.range || 'week';
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas } = await getMetrics(range, searchParams.from, searchParams.to);
const rangeLabels: Record<string, string> = {
day: 'Dernières 24h',
week: '7 derniers jours',
month: '30 derniers jours',
all: 'Depuis le début'
};
return (
<div className="space-y-8">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Metrics & Analytics</h1>
<p className="text-slate-400">Comportement des utilisateurs et engagement géographique en temps réel.</p>
</div>
<div className="bg-slate-800/50 border border-slate-700 px-4 py-2 rounded-lg text-xs font-mono text-slate-400">
Capture : <span className="text-emerald-400">{totalEvents.toLocaleString()}</span> événements total
</div>
</div>
{/* Controls: Shortcuts + Custom Calendar */}
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
{Object.entries(rangeLabels).map(([id, label]) => (
<Link
key={id}
href={`/metrics?range=${id}`}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
range === id && !searchParams.from
? 'bg-indigo-600 text-white shadow-lg'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
{label}
</Link>
))}
</div>
<form action="/metrics" className="flex items-center gap-2 bg-slate-900/50 p-2 rounded-xl border border-slate-800">
<Calendar className="w-4 h-4 text-slate-500 ml-2" />
<input
type="date"
name="from"
defaultValue={searchParams.from}
className="bg-transparent border-none text-xs text-white focus:ring-0 cursor-pointer"
/>
<span className="text-slate-600"></span>
<input
type="date"
name="to"
defaultValue={searchParams.to}
className="bg-transparent border-none text-xs text-white focus:ring-0 cursor-pointer"
/>
<button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white text-[10px] uppercase font-bold px-3 py-1.5 rounded-lg transition-colors">
Filtrer
</button>
</form>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="card border-t-4 border-indigo-500">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
<Users className="w-6 h-6" />
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">{uniqueVisitors}</div>
<ComparisonBadge value={deltas.visitors} />
</div>
</div>
<h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
</p>
</div>
<div className="card border-t-4 border-emerald-500">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-400">
<Eye className="w-6 h-6" />
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">{totalEvents}</div>
<ComparisonBadge value={deltas.events} />
</div>
</div>
<h3 className="text-slate-400 text-sm font-medium">Événements enregistrés</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
</p>
</div>
<div className="card border-t-4 border-amber-500">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400">
<Zap className="w-6 h-6" />
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">
{totalEvents > 0 ? Math.round((devices.find(d => d.device === 'Mobile')?._count.id || 0) / totalEvents * 100) : 0}%
</div>
<ComparisonBadge value={deltas.mobile} />
</div>
</div>
<h3 className="text-slate-400 text-sm font-medium">Part du trafic Mobile</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Countries Table */}
<div className="card p-0 overflow-hidden">
<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">
<Globe className="w-5 h-5 text-indigo-400" /> TOP 10 - Géographie
</h2>
</div>
<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">Pays</th>
<th className="px-6 py-4 text-right">Visites</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{topCountries.map((c) => (
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm text-slate-300 font-bold">{c.country || 'Inconnu'}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{c._count.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Device & Browser Stats */}
<div className="space-y-8">
<div className="card p-0 overflow-hidden">
<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">
<Zap className="w-5 h-5 text-amber-400" /> Appareils
</h2>
</div>
<div className="p-6 space-y-4">
{devices.map(d => (
<div key={d.device} className="space-y-1">
<div className="flex justify-between text-xs text-slate-400 uppercase font-bold">
<span>{d.device || 'Autre'}</span>
<span>{Math.round(d._count.id / totalEvents * 100)}%</span>
</div>
<div className="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
<div
className="bg-indigo-500 h-full transition-all duration-500"
style={{ width: `${(d._count.id / totalEvents * 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
<div className="card p-0 overflow-hidden">
<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">
<Search className="w-5 h-5 text-emerald-400" /> Navigateurs
</h2>
</div>
<div className="p-6 grid grid-cols-2 gap-4">
{browsers.map(b => (
<div key={b.browser} className="bg-slate-950/50 border border-slate-800 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-white mb-1">{b._count.id}</div>
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-widest">{b.browser || 'Autre'}</div>
</div>
))}
</div>
</div>
</div>
{/* Top Pages Table (Restored) */}
<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">
<TrendingUp className="w-5 h-5 text-rose-400" /> Pages les plus visitées
</h2>
</div>
<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">URL / Chemin</th>
<th className="px-6 py-4 text-right">Vues</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{topPages.map((page) => (
<tr key={page.path} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm font-mono text-slate-300">{page.path}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{page._count.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Recent IP Connections Table */}
<div className="card p-0 overflow-hidden lg:col-span-2">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-indigo-400" /> Dernières Connexions (IP)
</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">Adresse IP</th>
<th className="px-6 py-4">Localisation</th>
<th className="px-6 py-4 text-center">Appareil / OS</th>
<th className="px-6 py-4">Navigateur</th>
<th className="px-6 py-4">Dernière Page</th>
<th className="px-6 py-4 text-right">Date / Heure</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{recentIPs.map((e, idx) => e && (
<tr key={`${e.ip}-${idx}`} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4">
<span className="font-mono text-xs text-indigo-400 bg-indigo-400/5 px-2 py-1 rounded border border-indigo-400/10 whitespace-nowrap">
{e.ip || '---.---.---.---'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-sm text-slate-200 font-bold">{e.country || 'Inconnu'}</span>
<span className="text-[10px] text-slate-500">{e.city || '---'}</span>
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="inline-flex flex-col items-center">
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-bold mb-1 ${
e.device === 'Mobile' ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
}`}>
{e.device}
</span>
<span className="text-[10px] text-slate-400">{e.os}</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-300">
{e.browser}
</td>
<td className="px-6 py-4">
<span className="text-xs font-mono text-slate-400 truncate max-w-[150px] block" title={e.path}>
{e.path}
</span>
</td>
<td className="px-6 py-4 text-right text-xs whitespace-nowrap">
<div className="flex flex-col">
<span className="text-slate-300">{new Date(e.createdAt).toLocaleDateString('fr-FR')}</span>
<span className="text-slate-500">{new Date(e.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { getReports, getPendingRatings } from '@/app/actions/moderation';
import ReportActionButtons from '@/components/ReportActionButtons';
import ModerationSuspensionButton from '@/components/ModerationSuspensionButton';
import RatingModerationButtons from '@/components/RatingModerationButtons';
import { Flag, MessageCircle, User, Calendar, Star, CheckCircle } from 'lucide-react';
import Link from 'next/link';
export default async function ModerationPage({
searchParams
}: {
searchParams: Promise<{ tab?: string }>
}) {
const { tab = 'messages' } = await searchParams;
const reports = await getReports();
const pendingRatings = await getPendingRatings();
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
<Flag className="text-red-500" />
Centre de Modération
</h1>
<p className="text-slate-400">Gérer les signalements et valider les nouveaux avis.</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="/moderation?tab=messages"
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'messages' ? 'bg-brand-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<MessageCircle className="w-4 h-4" />
Messages ({reports.filter(r => r.status === 'PENDING').length})
</Link>
<Link
href="/moderation?tab=avis"
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'avis' ? 'bg-brand-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Star className="w-4 h-4" />
Avis ({pendingRatings.length})
</Link>
</div>
<div className="space-y-6">
{tab === 'messages' && (
<>
{reports.length === 0 ? (
<div className="card p-10 text-center text-slate-500">
Aucun signalement en attente.
</div>
) : (
reports.map((report) => (
<div key={report.id} className={`card border-l-4 ${report.status === 'PENDING' ? 'border-red-500' : report.status === 'RESOLVED' ? 'border-green-500' : 'border-slate-600'}`}>
<div className="flex flex-col md:flex-row gap-6">
<div className="md:w-1/3 space-y-4">
<div className="flex items-center gap-2 text-slate-400 text-sm">
<Calendar className="w-4 h-4" />
{new Date(report.createdAt).toLocaleString('fr-FR')}
</div>
<div>
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Signalé par</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-xs uppercase">{report.reporter.name.charAt(0)}</div>
<div>
<div className="text-sm font-bold text-white">{report.reporter.name}</div>
<div className="text-xs text-slate-500">{report.reporter.email}</div>
</div>
</div>
</div>
<div>
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Raison</div>
<p className="text-sm text-slate-300 italic">"{report.reason}"</p>
</div>
<div className="pt-2">
<span className={`badge ${
report.status === 'PENDING' ? 'badge-pending' : 'badge-verified'
}`}>
{report.status === 'PENDING' ? 'En attente' : 'Terminé'}
</span>
</div>
</div>
<div className="flex-1 bg-slate-900/40 rounded-xl border border-slate-800 p-4">
<Link
href={`/messages/${report.message.conversationId}`}
className="text-xs font-bold text-indigo-400 hover:text-indigo-300 flex items-center gap-1 mb-4"
>
Voir la conversation complète
</Link>
<div className="p-3 bg-slate-800/80 rounded-lg border border-red-500/20 mb-4">
<div className="text-[10px] text-red-400 font-bold uppercase mb-1">Message Signalé</div>
<div className="text-sm text-white">"{report.message.content}"</div>
<div className="text-[10px] text-slate-500 mt-1">Auteur: {report.message.sender.name}</div>
</div>
{report.status === 'PENDING' && (
<div className="flex flex-col gap-4 mt-6 pt-6 border-t border-slate-700">
<ReportActionButtons reportId={report.id} />
<ModerationSuspensionButton
userId={report.message.senderId}
userName={report.message.sender.name}
isSuspended={!!report.message.sender.isSuspended}
/>
</div>
)}
</div>
</div>
</div>
))
)}
</>
)}
{tab === 'avis' && (
<>
{pendingRatings.length === 0 ? (
<div className="card p-20 text-center">
<CheckCircle className="w-12 h-12 text-green-500/20 mx-auto mb-4" />
<h3 className="text-lg font-bold text-white mb-1">Tous les avis sont modérés</h3>
<p className="text-slate-500 text-sm">Beau travail ! Il n'y a plus aucun avis en attente.</p>
</div>
) : (
pendingRatings.map((r) => (
<div key={r.id} className="card border-l-4 border-amber-500 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="flex flex-col lg:flex-row gap-8">
<div className="lg:w-1/4 space-y-5">
<div className="flex items-center gap-2 text-slate-400 text-xs">
<Calendar className="w-3.5 h-3.5" />
{new Date(r.createdAt).toLocaleString('fr-FR')}
</div>
<div>
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">Auteur</div>
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-full bg-slate-800 flex items-center justify-center text-brand-400 font-bold text-sm uppercase">{r.user.name.charAt(0)}</div>
<div>
<div className="text-sm font-bold text-white">{r.user.name}</div>
<div className="text-[10px] text-slate-500">{r.user.email}</div>
</div>
</div>
</div>
<div>
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">Entrepreneur Concerné</div>
<div className="bg-slate-900/80 p-2.5 rounded-lg border border-slate-800">
<div className="text-sm font-bold text-indigo-400">{r.business.name}</div>
</div>
</div>
</div>
<div className="flex-1">
<div className="flex flex-col h-full">
<div className="flex-1 bg-slate-900/60 p-5 rounded-2xl border border-slate-800 relative">
<div className="flex text-yellow-500 mb-3">
{[1,2,3,4,5].map(s => (
<Star key={s} className={`w-4 h-4 ${s <= r.value ? 'fill-current' : 'text-slate-800'}`} />
))}
</div>
<p className="text-slate-200 text-sm leading-relaxed italic">
{r.comment || "Pas de commentaire."}
</p>
</div>
<div className="mt-6 pt-6 border-t border-slate-800 flex items-center justify-between">
<div className="text-[10px] text-slate-500 flex items-center gap-1">
<Flag className="w-3 h-3 text-amber-500" />
En attente de validation
</div>
<RatingModerationButtons ratingId={r.id} />
</div>
</div>
</div>
</div>
</div>
))
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import React from 'react';
import Link from 'next/link';
import { LayoutDashboard, ArrowLeft } from 'lucide-react';
export default function NotFound() {
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6">
<div className="max-w-md w-full text-center">
<div className="mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 mb-6">
<span className="text-4xl font-bold text-indigo-400">404</span>
</div>
<h1 className="text-3xl font-bold text-white mb-2">Page d'administration introuvable</h1>
<p className="text-slate-400">La ressource que vous recherchez n'existe pas ou a é déplacée.</p>
</div>
<div className="space-y-3">
<Link
href="/dashboard"
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition-colors"
>
<LayoutDashboard className="w-5 h-5" />
Retour au Dashboard
</Link>
<button
onClick={() => window.history.back()}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-slate-900 text-slate-300 border border-slate-800 rounded-xl font-semibold hover:bg-slate-800 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
Page précédente
</button>
</div>
</div>
</div>
);
}

5
admin/src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,151 @@
"use client";
import React, { useState, useEffect } from 'react';
import { getPricingPlans } from '@/app/actions/plans';
import { getOneShotPlans } from '@/app/actions/one-shot';
import { CreditCard, Edit3, CheckCircle2, Star, Loader2, Zap } from 'lucide-react';
import PlanEditModal from '@/components/PlanEditModal';
import OneShotPricingSettings from '@/components/OneShotPricingSettings';
export default function PlansPage() {
const [plans, setPlans] = useState<any[]>([]);
const [oneShotPlans, setOneShotPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editingPlan, setEditingPlan] = useState<any>(null);
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
useEffect(() => {
async function loadPlans() {
try {
const [plansData, oneShotData] = await Promise.all([
getPricingPlans(),
getOneShotPlans()
]);
setPlans(plansData);
setOneShotPlans(oneShotData || []);
} catch (error) {
console.error("Failed to load plans:", error);
} finally {
setLoading(false);
}
}
loadPlans();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center p-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
</div>
);
}
return (
<div className="p-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div>
<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 className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{plans.map((plan) => (
<div key={plan.id} className={`bg-slate-900 border ${plan.recommended ? 'border-indigo-500 ring-1 ring-indigo-500/50' : 'border-slate-800'} rounded-2xl overflow-hidden flex flex-col group transition-all hover:shadow-2xl hover:shadow-indigo-500/10`}>
{/* Header */}
<div className="p-6 bg-slate-800/50 border-b border-slate-800">
<div className="flex justify-between items-start mb-4">
<div className={`p-3 rounded-xl ${
plan.tier === 'STARTER' ? 'bg-slate-700/50 text-slate-400' :
plan.tier === 'BOOSTER' ? 'bg-indigo-500/20 text-indigo-400' :
'bg-amber-500/20 text-amber-400'
}`}>
{plan.tier === 'STARTER' ? <Zap className="w-5 h-5" /> :
plan.tier === 'BOOSTER' ? <Zap className="w-5 h-5" /> :
<Star className="w-5 h-5" />}
</div>
{plan.recommended && (
<span className="bg-indigo-600 text-[10px] font-bold uppercase tracking-widest px-2 py-1 rounded text-white shadow-lg">
Populaire
</span>
)}
</div>
<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>
</div>
{/* Price & Limit */}
<div className="p-6 bg-slate-950/30 flex-1">
<div className="mb-6">
<div className="text-3xl font-bold text-white mb-1">
{billingCycle === 'yearly'
? (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="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Avantages inclus :</div>
{plan.features.map((feature: string, idx: number) => (
<div key={idx} className="flex items-start gap-2 text-sm text-slate-300">
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
<span>{feature}</span>
</div>
))}
</div>
</div>
{/* Action */}
<div className="p-4 bg-slate-800/20 border-t border-slate-800">
<button
onClick={() => setEditingPlan(plan)}
className="w-full py-3 rounded-xl bg-slate-800 hover:bg-slate-700 text-white font-bold transition-all flex items-center justify-center gap-2 uppercase tracking-widest text-xs border border-slate-700"
>
<Edit3 className="w-4 h-4" />
Modifier le plan
</button>
</div>
</div>
))}
</div>
{editingPlan && (
<PlanEditModal
isOpen={!!editingPlan}
onClose={() => setEditingPlan(null)}
plan={editingPlan}
currentCycle={billingCycle}
/>
)}
{/* One Shot Plans Section */}
<div className="mt-16 pt-16 border-t border-slate-800">
<OneShotPricingSettings initialPlans={oneShotPlans} />
</div>
</div>
);
}

View File

@@ -0,0 +1,532 @@
"use client";
import React, { useState, useEffect, useTransition } from 'react';
import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase } from 'lucide-react';
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
import { toast } from 'react-hot-toast';
import { getBlogPosts } from '@/app/actions/actualites';
import RichTextEditor from '@/components/RichTextEditor';
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 [settings, setSettings] = useState<any>(null);
const [allPosts, setAllPosts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
const [resetPasswordTemplate, setResetPasswordTemplate] = useState('');
const [verifyEmailTemplate, setVerifyEmailTemplate] = useState('');
// Use a state for the tab if we want it to be fully client-side reactive without full reload
// But since the user wants it "like moderation center", I'll use URL search params
const [currentTab, setCurrentTab] = useState('general');
useEffect(() => {
async function loadData() {
try {
const [settingsData, postsData] = await Promise.all([
getSiteSettings(),
getBlogPosts()
]);
setSettings(settingsData);
setAllPosts(postsData);
setSelectedPostIds(settingsData?.homeBlogIds || []);
setSelectedHomeCategories(settingsData?.homeCategories || []);
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || '');
setVerifyEmailTemplate(settingsData?.verifyEmailTemplate || '');
} catch (error) {
console.error("Failed to load settings data:", error);
toast.error("Erreur lors du chargement des données");
} finally {
setLoading(false);
}
}
loadData();
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = {
siteName: formData.get('siteName'),
siteSlogan: formData.get('siteSlogan'),
contactEmail: formData.get('contactEmail'),
contactPhone: formData.get('contactPhone'),
address: formData.get('address'),
facebookUrl: formData.get('facebookUrl'),
twitterUrl: formData.get('twitterUrl'),
instagramUrl: formData.get('instagramUrl'),
linkedinUrl: formData.get('linkedinUrl'),
footerText: formData.get('footerText'),
homeBlogShow: formData.get('homeBlogShow') === 'on',
homeBlogTitle: formData.get('homeBlogTitle'),
homeBlogSubtitle: formData.get('homeBlogSubtitle'),
homeBlogCount: parseInt(formData.get('homeBlogCount') as string || '3'),
homeBlogSelection: formData.get('homeBlogSelection'),
homeBlogIds: selectedPostIds,
homeBlogCategories: selectedBlogCategories,
homeCategories: selectedHomeCategories,
resetPasswordSubject: formData.get('resetPasswordSubject'),
resetPasswordTemplate: resetPasswordTemplate,
verifyEmailSubject: formData.get('verifyEmailSubject'),
verifyEmailTemplate: verifyEmailTemplate,
};
startTransition(async () => {
const result = await updateSiteSettings(data);
if (result.success) {
toast.success("Paramètres mis à jour avec succès");
} else {
toast.error("Erreur lors de la mise à jour");
}
});
};
const togglePostId = (id: string) => {
setSelectedPostIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
const toggleHomeCategory = (cat: string) => {
setSelectedHomeCategories(prev =>
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
);
};
const toggleBlogCategory = (cat: string) => {
setSelectedBlogCategories(prev =>
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
);
};
if (loading) {
return (
<div className="flex items-center justify-center p-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
</div>
);
}
return (
<div className="p-8 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Configuration du Site</h1>
<p className="text-slate-400">Gérez les informations globales, les sections de l'accueil et les modèles d'emails.</p>
</div>
</div>
{/* Tabs Switcher */}
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
<button
onClick={() => setCurrentTab('general')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'general' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Globe className="w-4 h-4" />
Général
</button>
<button
onClick={() => setCurrentTab('emails')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'emails' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Mail className="w-4 h-4" />
Configuration Mail
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{currentTab === 'general' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Section Générale */}
<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">
<Globe className="w-5 h-5 text-indigo-400" />
<h2 className="font-semibold text-white">Informations Générales</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">Nom du site</label>
<input
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>
{/* Section Page d'Accueil - Secteurs */}
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
<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>
)}
<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>
</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,300 @@
import { prisma } from '@/lib/prisma';
import { getClients } from "@/app/actions/user";
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
import FeaturedModal from '@/components/FeaturedModal';
import EntrepreneurActions from '@/components/EntrepreneurActions';
import PlanSelector from '@/components/PlanSelector';
import ClientActions from "@/components/ClientActions";
import ManualVerifyButton from "@/components/ManualVerifyButton";
import { ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
import Link from 'next/link';
async function getBusinesses() {
return await prisma.business.findMany({
where: {
isActive: true
},
include: {
owner: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
export default async function UsersPage({
searchParams
}: {
searchParams: Promise<{ tab?: string }>
}) {
const { tab = 'entrepreneurs' } = await searchParams;
// Data for Entrepreneurs
const businesses = await getBusinesses();
const pendingCount = businesses.filter((b: any) =>
!b.verified &&
!b.isSuspended &&
['BOOSTER', 'EMPIRE'].includes(b.plan)
).length;
// Data for Clients/Visitors
const clientsResult = await getClients();
const clients = (clientsResult.success ? (clientsResult.data || []) : []) as any[];
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Utilisateurs</h1>
<p className="text-slate-400">Gérer les entrepreneurs et les visiteurs de la plateforme.</p>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
<Link
href="/users?tab=entrepreneurs"
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'entrepreneurs' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Briefcase className="w-4 h-4" />
Entrepreneurs ({businesses.length})
{pendingCount > 0 && (
<span className="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full">
{pendingCount}
</span>
)}
</Link>
<Link
href="/users?tab=visiteurs"
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'visiteurs' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<User className="w-4 h-4" />
Visiteurs ({clients.length})
</Link>
</div>
{tab === 'entrepreneurs' ? (
<div className="space-y-6">
{pendingCount > 0 && (
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-3 rounded-2xl flex items-center gap-4 animate-pulse max-w-fit">
<div className="w-10 h-10 bg-blue-500 rounded-xl flex items-center justify-center text-white">
<ShieldCheck className="w-6 h-6" />
</div>
<div>
<div className="text-blue-400 font-bold">{pendingCount} en attente</div>
<div className="text-xs text-blue-400/80 font-medium uppercase tracking-wider">Certifications Booster</div>
</div>
</div>
)}
<div className="card overflow-hidden p-0">
<table className="data-table">
<thead>
<tr>
<th>Entreprise</th>
<th>Catégorie</th>
<th>Propriétaire</th>
<th>Email</th>
<th>Statut</th>
<th className="text-center">Forfait</th>
<th className="text-center">Mise en avant</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody>
{businesses.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-10 text-slate-500">
Aucun entrepreneur trouvé.
</td>
</tr>
) : (
businesses.map((business: any) => (
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
<td>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
{business.logoUrl ? (
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
) : (
<span className="text-xs text-slate-500">LOGO</span>
)}
</div>
<div>
<div className="font-semibold text-white">{business.name}</div>
<div className="text-xs text-slate-500">{business.location}</div>
</div>
</div>
</td>
<td>
<span className="text-sm text-slate-300">{business.category}</span>
</td>
<td>
<div className="text-sm text-white">{business.owner.name}</div>
<div className="text-xs text-slate-500">{business.owner.email}</div>
</td>
<td>
{business.owner.emailVerified ? (
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
<MailCheck className="w-3.5 h-3.5" /> Vérifié
</span>
) : (
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
<MailX className="w-3.5 h-3.5" /> Non vérifié
</span>
<ManualVerifyButton userId={business.owner.id} />
</div>
)}
</td>
<td>
<div className="flex flex-col gap-1">
{business.owner?.isSuspended ? (
<span className="badge badge-error flex items-center gap-1">
<ShieldX className="w-3 h-3" /> Compte Suspendu
</span>
) : business.isSuspended ? (
<span className="badge bg-orange-500/10 text-orange-500 border border-orange-500/20 flex items-center gap-1">
<ShieldAlert className="w-3 h-3" /> Shop Masqué
</span>
) : business.verified ? (
<span className="badge badge-verified">Vérifié</span>
) : (
<span className="badge badge-pending">En attente</span>
)}
</div>
</td>
<td className="text-center">
<PlanSelector businessId={business.id} currentPlan={business.plan} />
</td>
<td className="text-center">
<FeaturedModal business={business} />
</td>
<td className="text-right">
<div className="flex items-center justify-end gap-3">
<ToggleVerifyButton id={business.id} verified={business.verified} />
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
<EntrepreneurActions
businessId={business.id}
businessName={business.name}
userId={business.owner.id}
userName={business.owner.name}
isBusinessSuspended={!!business.isSuspended}
isUserSuspended={!!business.owner?.isSuspended}
metaTitle={business.metaTitle}
metaDescription={business.metaDescription}
/>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Utilisateur</th>
<th>Contact</th>
<th>Vérification</th>
<th>Localisation</th>
<th>Statut</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{clients.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-slate-500">
<div className="flex flex-col items-center gap-2">
<Users className="w-8 h-8 opacity-20" />
<p>Aucun visiteur trouvé.</p>
</div>
</td>
</tr>
) : (
clients.map((client: any) => (
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
<td className="py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
{client.avatar ? (
<img src={client.avatar} alt={client.name} className="w-full h-full object-cover" />
) : (
client.name.charAt(0)
)}
</div>
<div>
<p className="text-white font-semibold">{client.name}</p>
<p className="text-slate-500 text-xs">ID: {client.id.split('-')[0]}...</p>
</div>
</div>
</td>
<td>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Mail className="w-3 h-3 text-slate-500" />
{client.email}
</div>
{client.phone && (
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Phone className="w-3 h-3 text-slate-500" />
{client.phone}
</div>
)}
</div>
</td>
<td>
{client.emailVerified ? (
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
<MailCheck className="w-3.5 h-3.5" /> Vérifié
</span>
) : (
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
<MailX className="w-3.5 h-3.5" /> Non vérifié
</span>
<ManualVerifyButton userId={client.id} />
</div>
)}
</td>
<td>
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<MapPin className="w-3 h-3" />
{client.location || "Non spécifié"}
</div>
</td>
<td>
<div className="flex flex-col gap-1">
{client.isSuspended ? (
<>
<span className="badge badge-error">Compte Suspendu</span>
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
{client.suspensionReason}
</span>
</>
) : client.businesses ? (
<span className="badge badge-pending">Shop Inactif</span>
) : (
<span className="badge bg-slate-800 text-slate-400">Actif</span>
)}
</div>
</td>
<td className="text-right">
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,257 @@
"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 } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
import ImageUploader from './ImageUploader';
import TagInput from './TagInput';
import { ContentStatus } from '@prisma/client';
import { getAllTags } from '@/app/actions/taxonomies';
interface Props {
initialData?: {
id: string;
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
slug?: string | null;
tags?: string[];
metaTitle?: string | null;
metaDescription?: string | null;
publishedAt?: Date | string | null;
status?: ContentStatus;
};
}
export default function BlogForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || '');
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
initialData?.publishedAt
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
);
const isPublished = new Date(publishedAtValue) <= new Date();
useEffect(() => {
getAllTags().then(setAllExistingTags);
}, []);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const publishedAtStr = formData.get('publishedAt') as string;
const data = {
title: formData.get('title') as string,
slug: formData.get('slug') as string,
excerpt: formData.get('excerpt') as string,
content: content,
author: formData.get('author') as string,
imageUrl: imageUrl, // Use state value
tags: tags, // Use state value
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
};
if (!data.imageUrl) {
toast.error("Une image est requise");
return;
}
startTransition(async () => {
const result = initialData
? await updateBlogPost(initialData.id, data)
: await createBlogPost(data);
if (result.success) {
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
router.push('/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>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6">
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
<div className="flex items-center gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
name="status"
defaultValue={initialData?.status || 'PUBLISHED'}
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
>
<option value="DRAFT">Brouillon</option>
<option value="PUBLISHED">Publié</option>
<option value="ARCHIVED">Archivé</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre</label>
<input
name="title"
defaultValue={initialData?.title}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: L'essor de la Tech en Afrique"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Auteur</label>
<input
name="author"
defaultValue={initialData?.author}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Jean Dupont"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<ImageUploader
label="Image de couverture"
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
</label>
<div className="relative">
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
<input
name="publishedAt"
type="datetime-local"
value={publishedAtValue}
onChange={(e) => setPublishedAtValue(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
<textarea
name="excerpt"
defaultValue={initialData?.excerpt}
required
rows={2}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Un court résumé de l'article..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Rédigez votre article ici..."
/>
</div>
</div>
{/* SEO SECTION */}
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
<input
name="slug"
defaultValue={initialData?.slug || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="ex: lessor-de-la-tech-afrique"
/>
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div>
<div className="space-y-2">
<TagInput
value={tags}
onChange={setTags}
suggestions={allExistingTags}
label="Mots-clés (tags)"
placeholder="tech, innovation, afrique..."
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input
name="metaTitle"
defaultValue={initialData?.metaTitle || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Titre optimisé pour les moteurs de recherche"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea
name="metaDescription"
defaultValue={initialData?.metaDescription || ''}
rows={3}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Description optimisée pour les moteurs de recherche (max 160 caractères)..."
/>
</div>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Créer l\'article'}
</button>
</div>
</form>
</div>
);
}

View File

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

@@ -0,0 +1,120 @@
"use client";
import React, { useState, useTransition } from 'react';
import { X, Save, Loader2, Globe } from 'lucide-react';
import { updateBusinessSeo } from '@/app/actions/business';
import { toast } from 'react-hot-toast';
interface BusinessSeoModalProps {
isOpen: boolean;
onClose: () => void;
business: {
id: string;
name: string;
metaTitle?: string | null;
metaDescription?: string | null;
};
}
export default function BusinessSeoModal({ isOpen, onClose, business }: BusinessSeoModalProps) {
const [isPending, startTransition] = useTransition();
const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
const [metaDescription, setMetaDescription] = useState(business.metaDescription || '');
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
const result = await updateBusinessSeo(business.id, {
metaTitle,
metaDescription
});
if (result.success) {
toast.success("Configuration SEO mise à jour");
onClose();
} else {
toast.error(result.error || "Erreur lors de la mise à jour");
}
});
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
<div className="p-6 border-b border-slate-800 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-500">
<Globe className="w-6 h-6" />
</div>
<div>
<h3 className="text-xl font-bold text-white">SEO & Partage</h3>
<p className="text-xs text-slate-400">Personnaliser l'aperçu WhatsApp pour {business.name}</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Titre (WhatsApp/Google)</label>
<input
value={metaTitle}
onChange={(e) => setMetaTitle(e.target.value)}
placeholder={`Ex: ${business.name} - Expert en ...`}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-all"
/>
<p className="text-[10px] text-slate-500 italic">Recommandé : Moins de 60 caractères.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Description</label>
<textarea
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
rows={4}
placeholder="Décrivez l'entreprise en quelques mots pour attirer les clics..."
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-all resize-none"
/>
<p className="text-[10px] text-slate-500 italic">Recommandé : Entre 140 et 160 caractères.</p>
</div>
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-xl p-4">
<h4 className="text-[10px] font-bold text-indigo-400 uppercase tracking-widest mb-2">Aperçu du partage</h4>
<div className="bg-slate-950 rounded-lg p-3 border border-slate-800">
<div className="text-sm font-bold text-indigo-400 line-clamp-1">{metaTitle || business.name}</div>
<div className="text-xs text-slate-500 line-clamp-2 mt-1">{metaDescription || "Découvrez cette entreprise sur Afroprenariat..."}</div>
<div className="text-[10px] text-slate-600 mt-2">afroprenariat.com</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 rounded-xl border border-slate-800 text-slate-400 font-bold hover:bg-slate-800 transition-all"
>
Annuler
</button>
<button
type="submit"
disabled={isPending}
className="flex-[2] py-3 rounded-xl bg-indigo-600 text-white font-bold hover:bg-indigo-500 transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20"
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Save className="w-5 h-5" />
)}
Enregistrer
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import React, { useState } from 'react';
import { ShieldAlert, ShieldCheck } from 'lucide-react';
import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal';
interface ClientActionsProps {
userId: string;
userName: string;
isSuspended: boolean;
}
export default function ClientActions({ userId, userName, isSuspended }: ClientActionsProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleSuspend = async (reason: string) => {
const result = await suspendUser(userId, reason);
if (result.success) {
toast.success(`${userName} a été suspendu.`);
} else {
toast.error(result.error || "Échec de la suspension");
}
};
const handleUnsuspend = async () => {
const result = await unsuspendUser(userId);
if (result.success) {
toast.success(`${userName} a été réactivé.`);
} else {
toast.error(result.error || "Échec de la réactivation");
}
};
if (isSuspended) {
return (
<button
onClick={handleUnsuspend}
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-green-500/20"
>
<ShieldCheck className="w-3 h-3" />
Réactiver
</button>
);
}
return (
<>
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-rose-500/20"
>
<ShieldAlert className="w-3 h-3" />
Suspendre
</button>
<SuspensionModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onConfirm={handleSuspend}
title="Suspendre le compte"
subtitle={`Voulez-vous suspendre le compte de ${userName} ? L'utilisateur sera bloqué et recevra votre message.`}
/>
</>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { useTransition } from 'react';
import { deleteBlogPost } from '@/app/actions/actualites';
import { Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function DeleteActualitesButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Supprimer cet article ?")) {
startTransition(async () => {
const result = await deleteBlogPost(id);
if (result.success) {
toast.success("Article supprimé");
} 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">
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { useTransition } from 'react';
import { deleteComment } from '@/app/actions/comment';
import { Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface Props {
id: string;
}
export default function DeleteCommentButton({ id }: Props) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Êtes-vous sûr de vouloir supprimer ce commentaire ?")) {
startTransition(async () => {
const result = await deleteComment(id);
if (result.success) {
toast.success("Commentaire supprimé");
} 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 hover:bg-red-400/10 rounded-lg transition-colors"
title="Supprimer"
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Trash2 className="w-5 h-5" />
)}
</button>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { useTransition } from 'react';
import { deleteEvent } from '@/app/actions/event';
import { Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface Props {
id: string;
}
export default function DeleteEventButton({ id }: Props) {
const [isPending, startTransition] = useTransition();
const handleDelete = async () => {
if (!confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')) return;
startTransition(async () => {
const result = await deleteEvent(id);
if (result.success) {
toast.success("Événement supprimé");
} else {
toast.error(result.error || "Erreur lors de la suppression");
}
});
};
return (
<button
onClick={handleDelete}
disabled={isPending}
className="p-2 text-slate-400 hover:text-rose-500 transition-colors"
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Trash2 className="w-5 h-5" />
)}
</button>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { useTransition } from 'react';
import { deleteInterview } from '@/app/actions/interview';
import { Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function DeleteInterviewButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Supprimer cette interview ?")) {
startTransition(async () => {
const result = await deleteInterview(id);
if (result.success) {
toast.success("Interview 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">
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
);
}

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

@@ -0,0 +1,125 @@
"use client";
import React, { useState } from 'react';
import { ShieldAlert, ShieldCheck, ShoppingBag, User, Globe } from 'lucide-react';
import { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } from '@/app/actions/suspension';
import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal';
import BusinessSeoModal from './BusinessSeoModal';
interface EntrepreneurActionsProps {
businessId: string;
businessName: string;
userId: string;
userName: string;
isBusinessSuspended: boolean;
isUserSuspended: boolean;
metaTitle?: string | null;
metaDescription?: string | null;
}
export default function EntrepreneurActions({
businessId,
businessName,
userId,
userName,
isBusinessSuspended,
isUserSuspended,
metaTitle,
metaDescription
}: EntrepreneurActionsProps) {
const [modalType, setModalType] = useState<'USER' | 'BUSINESS' | null>(null);
const [seoModalOpen, setSeoModalOpen] = useState(false);
const handleConfirm = async (reason: string) => {
if (modalType === 'USER') {
const result = await suspendUser(userId, reason);
if (result.success) toast.success(`Compte de ${userName} suspendu.`);
else toast.error(result.error || "Erreur");
} else if (modalType === 'BUSINESS') {
const result = await suspendBusiness(businessId, reason);
if (result.success) toast.success(`Boutique ${businessName} suspendue.`);
else toast.error(result.error || "Erreur");
}
setModalType(null);
};
return (
<div className="flex flex-col gap-2">
<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 */}
{isUserSuspended ? (
<button
onClick={() => unsuspendUser(userId)}
className="btn-action bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white"
title="Activer le compte"
>
<ShieldCheck className="w-4 h-4" />
<span className="text-[10px] uppercase font-bold">Compte</span>
</button>
) : (
<button
onClick={() => setModalType('USER')}
className="btn-action bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white"
title="Suspendre le compte complet"
>
<User className="w-4 h-4" />
<span className="text-[10px] uppercase font-bold">Compte</span>
</button>
)}
{/* Business Suspension */}
{isBusinessSuspended ? (
<button
onClick={() => unsuspendBusiness(businessId)}
className="btn-action bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500 hover:text-white"
title="Activer la boutique"
>
<ShieldCheck className="w-4 h-4" />
<span className="text-[10px] uppercase font-bold">Shop</span>
</button>
) : (
<button
onClick={() => setModalType('BUSINESS')}
className="btn-action bg-orange-500/10 text-orange-500 hover:bg-orange-500 hover:text-white"
title="Suspendre uniquement la boutique"
>
<ShoppingBag className="w-4 h-4" />
<span className="text-[10px] uppercase font-bold">Shop</span>
</button>
)}
</div>
<SuspensionModal
isOpen={modalType !== null}
onClose={() => setModalType(null)}
onConfirm={handleConfirm}
title={modalType === 'USER' ? "Suspendre l'utilisateur" : "Suspendre la boutique"}
subtitle={modalType === 'USER'
? `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.`}
/>
<BusinessSeoModal
isOpen={seoModalOpen}
onClose={() => setSeoModalOpen(false)}
business={{
id: businessId,
name: businessName,
metaTitle,
metaDescription
}}
/>
</div>
);
}

View File

@@ -0,0 +1,264 @@
"use client";
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createEvent, updateEvent } from '@/app/actions/event';
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } from 'lucide-react';
import 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?: any;
}
export default function EventForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [description, setDescription] = useState(initialData?.description || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
initialData?.publishedAt
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
);
const isPublished = new Date(publishedAtValue) <= new Date();
useEffect(() => {
getAllTags().then(setAllExistingTags);
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const publishedAtStr = formData.get('publishedAt') as string;
const data: any = {
title: formData.get('title') as string,
slug: formData.get('slug') as string,
location: formData.get('location') as string,
date: new Date(formData.get('date') as string),
thumbnailUrl: thumbnailUrl, // Use state value
link: formData.get('link') as string || null,
description: description || '',
tags: tags, // Use state
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
};
if (!data.thumbnailUrl) {
toast.error("Une image est requise");
return;
}
startTransition(async () => {
const result = initialData
? await updateEvent(initialData.id, data)
: await createEvent(data);
if (result.success) {
toast.success(initialData ? "Événement mis à jour" : "Événement 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\'événement' : 'Nouvel Événement'}
</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6">
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
<div className="flex items-center gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
name="status"
defaultValue={initialData?.status || 'PUBLISHED'}
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
>
<option value="DRAFT">Brouillon</option>
<option value="PUBLISHED">Publié</option>
<option value="ARCHIVED">Archivé</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre de l'événement</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: Conférence AfroHub 2026"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Date de l'événement (Début)</label>
<div className="relative">
<Calendar className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
name="date"
type="datetime-local"
defaultValue={initialData?.date ? new Date(initialData.date).toISOString().slice(0, 16) : ''}
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"
/>
</div>
</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">Lieu</label>
<div className="relative">
<MapPin className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
name="location"
defaultValue={initialData?.location}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Abidjan, Côte-d'Ivoire / Zoom"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
</label>
<div className="relative">
<Send className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
name="publishedAt"
type="datetime-local"
value={publishedAtValue}
onChange={(e) => setPublishedAtValue(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<ImageUploader
label="Image (Affiche/Miniature)"
value={thumbnailUrl}
onChange={setThumbnailUrl}
name="thumbnailUrl"
/>
</div>
<div className="space-y-2">
<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={initialData?.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 className="space-y-2">
<label className="text-sm font-medium text-slate-400">Description de l'événement</label>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Détails sur l'événement, programme, intervenants..."
/>
</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: conference-afrohub-2026"
/>
<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="evenement, networking, afrique..."
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input
name="metaTitle"
defaultValue={initialData?.metaTitle || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Titre optimisé pour Google"
/>
</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 courte pour les résultats de recherche..."
/>
</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 bg-indigo-600"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Créer l\'événement'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useState, useTransition } from 'react';
import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business';
import { Star, X, Loader2, Save } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface Props {
business: {
id: string;
name: string;
isFeatured: boolean;
founderName?: string | null;
founderImageUrl?: string | null;
keyMetric?: string | null;
};
}
export default function FeaturedModal({ business }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = {
founderName: formData.get('founderName') as string,
founderImageUrl: formData.get('founderImageUrl') as string,
keyMetric: formData.get('keyMetric') as string,
};
startTransition(async () => {
const result = await setFeaturedBusiness(business.id, data);
if (result.success) {
toast.success("Afroshine mis à jour !");
setIsOpen(false);
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
const handleRemove = async () => {
if (confirm("Retirer cet entrepreneur du titre 'Afroshine' ?")) {
startTransition(async () => {
const result = await removeFeaturedBusiness(business.id);
if (result.success) {
toast.success("Titre révoqué");
setIsOpen(false);
} else {
toast.error(result.error || "Une erreur est survenue lors de la suppression");
}
});
}
};
return (
<>
<button
onClick={() => setIsOpen(true)}
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-all flex items-center gap-2 mx-auto ${
business.isFeatured
? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20'
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
}`}
>
<Star className={`w-4 h-4 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
{business.isFeatured ? 'Vedette Active' : 'Mettre en avant'}
</button>
{isOpen && (
<div className="modal-overlay">
<div className="modal-content">
<button
onClick={() => setIsOpen(false)}
className="absolute top-4 right-4 text-slate-500 hover:text-white"
>
<X className="w-6 h-6" />
</button>
<div className="mb-6">
<h2 className="text-2xl font-bold text-white mb-2">💰 Afroshine</h2>
<p className="text-slate-400">Configurez les détails pour {business.name}.</p>
</div>
<form onSubmit={handleToggleFeature} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du fondateur</label>
<input
name="founderName"
defaultValue={business.founderName || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="Ex: Aliko Dangote"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur</label>
<input
name="founderImageUrl"
defaultValue={business.founderImageUrl || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Métrique Clé (ex: Chiffre d'affaires, Impact)</label>
<input
name="keyMetric"
defaultValue={business.keyMetric || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="Ex: +500 emplois créés"
/>
</div>
<div className="pt-4 flex flex-col gap-3">
<button
type="submit"
disabled={isPending}
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
Définir comme Afroshine
</button>
{business.isFeatured && (
<button
type="button"
onClick={handleRemove}
disabled={isPending}
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
>
Révoquer le titre
</button>
)}
</div>
</form>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,174 @@
'use client';
import React, { useState, useRef } from 'react';
import { Upload, Link as LinkIcon, X, Image as ImageIcon, Loader2 } from 'lucide-react';
import { uploadImage } from '@/app/actions/upload';
import { toast } from 'react-hot-toast';
interface Props {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
name?: string;
}
export default function ImageUploader({ value, onChange, label, placeholder, name }: Props) {
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Basic validation
if (!file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image");
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error("L'image ne doit pas dépasser 5 Mo");
return;
}
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
const result = await uploadImage(formData);
if (result.success && result.url) {
onChange(result.url);
toast.success("Image uploadée avec succès");
} else {
toast.error(result.error || "Erreur lors de l'upload");
}
} catch (error) {
toast.error("Erreur de connexion lors de l'upload");
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const clearImage = () => {
onChange('');
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-400">{label || "Image"}</label>
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-800">
<button
type="button"
onClick={() => setMode('url')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
mode === 'url' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
<LinkIcon className="w-3.5 h-3.5" />
Lien URL
</button>
<button
type="button"
onClick={() => setMode('upload')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
mode === 'upload' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
<Upload className="w-3.5 h-3.5" />
Upload
</button>
</div>
</div>
<div className="relative group">
{mode === 'url' ? (
<div className="relative">
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
type="text"
name={name}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "https://images.unsplash.com/..."}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
) : (
<div className="space-y-4">
{!value ? (
<div
onClick={() => fileInputRef.current?.click()}
className="cursor-pointer border-2 border-dashed border-slate-700 hover:border-indigo-500 rounded-xl p-8 flex flex-col items-center justify-center gap-3 bg-slate-900/50 hover:bg-slate-900 transition-all group"
>
{isUploading ? (
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
) : (
<div className="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center group-hover:bg-indigo-500/20 group-hover:text-indigo-400 transition-colors">
<Upload className="w-6 h-6 text-slate-400 group-hover:text-indigo-400" />
</div>
)}
<div className="text-center">
<p className="text-sm font-medium text-white">
{isUploading ? "Upload en cours..." : "Cliquez pour uploader"}
</p>
<p className="text-xs text-slate-500 mt-1">PNG, JPG ou WEBP jusqu'à 5 Mo</p>
</div>
</div>
) : (
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
<img
src={value}
alt="Prévisualisation"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
>
<Upload className="w-5 h-5" />
</button>
<button
type="button"
onClick={clearImage}
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Hidden input for value to be sent via form if needed */}
<input type="hidden" name={name} value={value || ""} />
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
</div>
)}
</div>
{value && mode === 'url' && (
<div className="mt-3 relative rounded-lg overflow-hidden aspect-video border border-slate-800">
<img src={value} alt="Preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={clearImage}
className="absolute top-2 right-2 p-1.5 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-black transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,300 @@
"use client";
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createInterview, updateInterview } from '@/app/actions/interview';
import { Loader2, ArrowLeft, Save, Video, FileText, Calendar } from 'lucide-react';
import Link from 'next/link';
import { InterviewType, ContentStatus } from '@prisma/client';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
import ImageUploader from './ImageUploader';
import TagInput from './TagInput';
import { getAllTags } from '@/app/actions/taxonomies';
interface Props {
initialData?: any;
}
export default function InterviewForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
const [publishedAtValue, setPublishedAtValue] = useState(
initialData?.publishedAt
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
);
const isPublished = new Date(publishedAtValue) <= new Date();
useEffect(() => {
getAllTags().then(setAllExistingTags);
}, []);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const publishedAtStr = formData.get('publishedAt') as string;
const data: any = {
title: formData.get('title') as string,
slug: formData.get('slug') as string,
guestName: formData.get('guestName') as string,
companyName: formData.get('companyName') as string,
role: formData.get('role') as string,
type: formData.get('type') as InterviewType,
thumbnailUrl: thumbnailUrl, // Use state value
videoUrl: formData.get('videoUrl') as string || null,
excerpt: formData.get('excerpt') as string,
content: content || null,
duration: formData.get('duration') as string || null,
tags: tags, // Use state
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
status: formData.get('status') as ContentStatus,
};
if (!data.thumbnailUrl) {
toast.error("Une miniature est requise");
return;
}
startTransition(async () => {
const result = initialData
? await updateInterview(initialData.id, data)
: await createInterview(data);
if (result.success) {
toast.success(initialData ? "Interview mise à jour" : "Interview créée 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\'interview' : 'Nouvelle Interview'}
</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6">
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
<div className="flex items-center gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
name="status"
defaultValue={initialData?.status || 'PUBLISHED'}
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
>
<option value="DRAFT">Brouillon</option>
<option value="PUBLISHED">Publié</option>
<option value="ARCHIVED">Archivé</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre de l'interview</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: Entreprise X : Une success story..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom de l'invité</label>
<input
name="guestName"
defaultValue={initialData?.guestName}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Sarah Koné"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Entreprise</label>
<input
name="companyName"
defaultValue={initialData?.companyName}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: TechAfrica"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
<input
name="role"
defaultValue={initialData?.role}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Fondatrice & CEO"
/>
</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">Type d'interview</label>
<select
name="type"
defaultValue={initialData?.type || 'VIDEO'}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
>
<option value="VIDEO">Vidéo</option>
<option value="ARTICLE">Article (Texte)</option>
</select>
</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="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<ImageUploader
label="Miniature (Thumbnail)"
value={thumbnailUrl}
onChange={setThumbnailUrl}
name="thumbnailUrl"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label>
<input
name="videoUrl"
defaultValue={initialData?.videoUrl}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://youtube.com/..."
/>
<div className="space-y-2 mt-4">
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
<input
name="duration"
defaultValue={initialData?.duration}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: 12 min"
/>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</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"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu / Transcription (Optionnel)</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Rédigez la transcription ou le contenu 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: interview-sarah-kone-techafrica"
/>
<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, succes, interview..."
/>
</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é SEO"
/>
</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 courte SEO..."
/>
</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 bg-emerald-600"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Créer l\'interview'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import React, { useState } from 'react';
import { LegalDocument } from '@prisma/client';
import RichTextEditor from './RichTextEditor';
import { updateLegalDocument } from '@/app/actions/legal';
import { toast } from 'react-hot-toast';
import { FileText, Save, Loader2 } from 'lucide-react';
interface Props {
initialCgu: LegalDocument | null;
initialCgv: LegalDocument | null;
}
export default function LegalEditor({ initialCgu, initialCgv }: Props) {
const [activeTab, setActiveTab] = useState<'CGU' | 'CGV'>('CGU');
const [cgu, setCgu] = useState({
title: initialCgu?.title || "Conditions Générales d'Utilisation",
content: initialCgu?.content || ""
});
const [cgv, setCgv] = useState({
title: initialCgv?.title || "Conditions Générales de Vente",
content: initialCgv?.content || ""
});
const [isSaving, setIsSaving] = useState(false);
const currentDoc = activeTab === 'CGU' ? cgu : cgv;
const handleUpdate = (field: string, value: string) => {
if (activeTab === 'CGU') {
setCgu(prev => ({ ...prev, [field]: value }));
} else {
setCgv(prev => ({ ...prev, [field]: value }));
}
};
const handleSave = async () => {
setIsSaving(true);
try {
const result = await updateLegalDocument(activeTab, currentDoc.title, currentDoc.content);
if (result.success) {
toast.success(`${activeTab} mis à jour avec succès`);
} else {
toast.error(result.error || "Une erreur est survenue");
}
} catch (error) {
toast.error("Erreur de connexion au serveur");
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-6">
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
<button
onClick={() => setActiveTab('CGU')}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === 'CGU'
? 'bg-indigo-600 text-white shadow-lg'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
CGU
</button>
<button
onClick={() => setActiveTab('CGV')}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === 'CGV'
? 'bg-indigo-600 text-white shadow-lg'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
CGV
</button>
</div>
<div className="card">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-600/20 text-indigo-400 rounded-xl flex items-center justify-center">
<FileText className="w-5 h-5" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Modifier {activeTab}</h2>
<p className="text-sm text-slate-400">Dernière mise à jour : {new Date().toLocaleDateString('fr-FR')}</p>
</div>
</div>
<button
onClick={handleSave}
disabled={isSaving}
className="btn-primary flex items-center gap-2"
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Enregistrer les modifications
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5 ml-1">Titre du document</label>
<input
type="text"
value={currentDoc.title}
onChange={(e) => handleUpdate('title', e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
placeholder="Ex: Conditions Générales d'Utilisation"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5 ml-1">Contenu</label>
<RichTextEditor
value={currentDoc.content}
onChange={(val) => handleUpdate('content', val)}
placeholder={`Commencez à rédiger vos ${activeTab}...`}
/>
</div>
</div>
</div>
<div className="bg-amber-500/10 border border-amber-500/30 p-4 rounded-xl flex items-center gap-3 text-amber-500">
<div className="shrink-0 w-8 h-8 rounded-full bg-amber-500/20 flex items-center justify-center">!</div>
<p className="text-sm font-medium">
Attention : Les modifications seront immédiatement visibles pour tous les utilisateurs sur le site public une fois enregistrées.
</p>
</div>
</div>
);
}

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,62 @@
"use client";
import React, { useState } from 'react';
import { ShieldAlert, ShieldCheck } from 'lucide-react';
import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal';
interface ModerationSuspensionButtonProps {
userId: string;
userName: string;
isSuspended: boolean;
}
export default function ModerationSuspensionButton({
userId,
userName,
isSuspended
}: ModerationSuspensionButtonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleSuspend = async (reason: string) => {
const result = await suspendUser(userId, reason);
if (result.success) {
toast.success(`${userName} a été suspendu.`);
} else {
toast.error(result.error || "Échec de la suspension");
}
};
if (isSuspended) {
return (
<button
onClick={() => unsuspendUser(userId)}
className="flex items-center gap-1.5 px-3 py-2 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white rounded-lg transition-all text-xs font-bold"
>
<ShieldCheck className="w-4 h-4" />
Réactiver le compte de l'auteur
</button>
);
}
return (
<>
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-1.5 px-3 py-2 bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white rounded-lg transition-all text-xs font-bold"
>
<ShieldAlert className="w-4 h-4" />
Suspendre l'auteur du message
</button>
<SuspensionModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onConfirm={handleSuspend}
title="Suspendre l'auteur"
subtitle={`Voulez-vous suspendre le compte de ${userName} pour ce message non conforme ?`}
/>
</>
);
}

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

@@ -0,0 +1,248 @@
"use client";
import React, { useState } from 'react';
import { X, Plus, Trash2, Save, Loader2 } from 'lucide-react';
import { updatePricingPlanDetail } from '@/app/actions/plans';
import { toast } from 'react-hot-toast';
interface PlanEditModalProps {
isOpen: boolean;
onClose: () => void;
plan: any;
currentCycle: 'monthly' | 'yearly';
}
export default function PlanEditModal({ isOpen, onClose, plan, currentCycle }: PlanEditModalProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: plan.name,
priceXOF: plan.priceXOF,
yearlyPriceXOF: plan.yearlyPriceXOF || '',
priceEUR: plan.priceEUR,
yearlyPriceEUR: plan.yearlyPriceEUR || '',
description: plan.description,
offerLimit: plan.offerLimit.toString(),
recommended: plan.recommended,
color: plan.color,
});
const [features, setFeatures] = useState<string[]>(plan.features || []);
const handleAddFeature = () => {
setFeatures([...features, '']);
};
const handleFeatureChange = (index: number, value: string) => {
const newFeatures = [...features];
newFeatures[index] = value;
setFeatures(newFeatures);
};
const handleRemoveFeature = (index: number) => {
setFeatures(features.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const result = await updatePricingPlanDetail(plan.id, {
...formData,
features: features.filter(f => f.trim() !== ''),
});
if (result.success) {
toast.success("Détails du forfait mis à jour !");
onClose();
window.location.reload(); // Refresh to catch changes
} else {
toast.error(result.error || "Erreur lors de la sauvegarde.");
}
} catch (error) {
toast.error("Erreur réseau");
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 sm:p-6">
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity" onClick={onClose}></div>
<div className="relative bg-slate-900 border border-slate-700 w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col transition-all">
{/* Header */}
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/50">
<div>
<h2 className="text-xl font-bold text-white font-serif">Modifier le plan {plan.tier}</h2>
<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>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto flex-1">
<form id="plan-form" onSubmit={handleSubmit} className="space-y-6">
<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 font-sans uppercase tracking-wider">Nom affiché</label>
<input
value={formData.name}
onChange={(e) => setFormData({...formData, name: 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">Limite d'offres</label>
<input
type="number"
value={formData.offerLimit}
onChange={(e) => setFormData({...formData, offerLimit: 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>
{currentCycle === 'monthly' ? (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Mensuel)</label>
<input
value={formData.priceXOF}
onChange={(e) => setFormData({...formData, priceXOF: 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 EUR (Mensuel)</label>
<input
value={formData.priceEUR}
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 className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Description</label>
<textarea
rows={2}
value={formData.description}
onChange={(e) => setFormData({...formData, description: 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 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Couleur (Tailwind)</label>
<select
value={formData.color}
onChange={(e) => setFormData({...formData, color: 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"
>
<option value="gray">Gris (Starter)</option>
<option value="brand">Brand (Booster)</option>
<option value="amber">Amber (Empire)</option>
<option value="rose">Rose</option>
<option value="blue">Blue</option>
</select>
</div>
<div className="flex items-end pb-3">
<label className="flex items-center gap-3 cursor-pointer group">
<div className={`w-10 h-6 rounded-full transition-colors relative ${formData.recommended ? 'bg-indigo-600' : 'bg-slate-700'}`}>
<div className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${formData.recommended ? 'translate-x-4' : ''}`} />
</div>
<input
type="checkbox"
className="sr-only"
checked={formData.recommended}
onChange={(e) => setFormData({...formData, recommended: e.target.checked})}
/>
<span className="text-sm font-medium text-slate-300 group-hover:text-white transition-colors uppercase tracking-wider">Mettre en avant</span>
</label>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Avantages du plan</label>
<button
type="button"
onClick={handleAddFeature}
className="text-xs font-bold text-indigo-400 hover:text-indigo-300 flex items-center gap-1 uppercase tracking-tight"
>
<Plus className="w-3 h-3" /> Ajouter un avantage
</button>
</div>
<div className="space-y-3">
{features.map((feature, index) => (
<div key={index} className="flex gap-2">
<input
value={feature}
onChange={(e) => handleFeatureChange(index, e.target.value)}
placeholder="Ex: 50 Offres produits"
className="flex-1 bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
<button
type="button"
onClick={() => handleRemoveFeature(index)}
className="p-3 text-slate-500 hover:text-rose-500 bg-slate-950 border border-slate-700 rounded-xl transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</form>
</div>
{/* Footer */}
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 rounded-xl font-bold text-slate-400 hover:text-white border border-slate-700 hover:bg-slate-700 transition-all uppercase tracking-wider text-sm"
>
Annuler
</button>
<button
type="submit"
form="plan-form"
disabled={loading}
className="px-8 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white rounded-xl font-bold transition-all shadow-lg flex items-center gap-2 uppercase tracking-wider text-sm"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
Sauvegarder
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import React, { useState } from 'react';
import { updateBusinessPlan } from '@/app/actions/plans';
import { toast } from 'react-hot-toast';
import { CreditCard, Loader2 } from 'lucide-react';
interface PlanSelectorProps {
businessId: string;
currentPlan: string;
}
const PLANS = [
{ id: 'STARTER', name: 'Starter', color: 'text-slate-400', bg: 'bg-slate-400/10' },
{ id: 'BOOSTER', name: 'Booster', color: 'text-brand-400', bg: 'bg-brand-400/10' },
{ id: 'EMPIRE', name: 'Empire', color: 'text-amber-400', bg: 'bg-amber-400/10' }
];
export default function PlanSelector({ businessId, currentPlan }: PlanSelectorProps) {
const [isUpdating, setIsUpdating] = useState(false);
const [plan, setPlan] = useState(currentPlan);
const handlePlanChange = async (newPlan: string) => {
setIsUpdating(true);
try {
const result = await updateBusinessPlan(businessId, newPlan as any);
if (result.success) {
setPlan(newPlan);
toast.success(`Forfait mis à jour vers ${newPlan}`);
} else {
toast.error(result.error || "Erreur lors du changement de forfait");
// Reset to old plan in UI
setPlan(currentPlan);
}
} catch (error) {
toast.error("Erreur réseau");
setPlan(currentPlan);
} finally {
setIsUpdating(false);
}
};
return (
<div className="relative inline-flex items-center group">
<div className={`absolute left-2 text-slate-500 pointer-events-none ${isUpdating ? 'animate-spin' : ''}`}>
{isUpdating ? <Loader2 className="w-3.5 h-3.5" /> : <CreditCard className="w-3.5 h-3.5" />}
</div>
<select
value={plan}
onChange={(e) => handlePlanChange(e.target.value)}
disabled={isUpdating}
className={`appearance-none bg-slate-900 border border-slate-700 pl-8 pr-8 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider focus:outline-none focus:ring-2 focus:ring-brand-500/50 transition-all cursor-pointer hover:border-slate-600 disabled:opacity-50 disabled:cursor-not-allowed ${
PLANS.find(p => p.id === plan)?.color || 'text-white'
}`}
>
{PLANS.map((p) => (
<option key={p.id} value={p.id} className="bg-slate-900 text-white font-sans capitalize">
{p.name}
</option>
))}
</select>
<div className="absolute right-2 pointer-events-none">
<svg className="w-3 h-3 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useState } from 'react';
import { Check, X, Loader2 } from 'lucide-react';
import { updateRatingStatus } from '@/app/actions/moderation';
import { toast } from 'react-hot-toast';
interface RatingModerationButtonsProps {
ratingId: string;
}
export default function RatingModerationButtons({ ratingId }: RatingModerationButtonsProps) {
const [isPending, setIsPending] = useState(false);
const handleAction = async (status: 'APPROVED' | 'REJECTED') => {
setIsPending(true);
try {
const result = await updateRatingStatus(ratingId, status);
if (result.success) {
toast.success(status === 'APPROVED' ? "Avis approuvé !" : "Avis rejeté.");
} else {
toast.error(result.error || "Une erreur est survenue");
}
} catch (error) {
toast.error("Erreur lors de l'opération");
} finally {
setIsPending(false);
}
};
return (
<div className="flex gap-2">
<button
onClick={() => handleAction('APPROVED')}
disabled={isPending}
className="flex items-center gap-1.5 bg-brand-600 hover:bg-brand-700 text-white px-3 py-1.5 rounded-lg text-xs font-bold transition-all disabled:opacity-50"
>
{isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
Approuver
</button>
<button
onClick={() => handleAction('REJECTED')}
disabled={isPending}
className="flex items-center gap-1.5 bg-slate-800 hover:bg-red-500/20 hover:text-red-500 text-slate-400 px-3 py-1.5 rounded-lg text-xs font-bold transition-all border border-slate-700 disabled:opacity-50"
>
{isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <X className="w-3.5 h-3.5" />}
Rejeter
</button>
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { useTransition } from 'react';
import { updateReportStatus } from '@/app/actions/moderation';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface Props {
reportId: string;
}
export default function ReportActionButtons({ reportId }: Props) {
const [isPending, startTransition] = useTransition();
const handleAction = (status: 'RESOLVED' | 'DISMISSED') => {
startTransition(async () => {
const result = await updateReportStatus(reportId, status);
if (result.success) {
toast.success(status === 'RESOLVED' ? "Signalement résolu" : "Signalement rejeté");
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
return (
<div className="flex gap-2 justify-end">
<button
onClick={() => handleAction('RESOLVED')}
disabled={isPending}
className="btn-verify px-3 py-1.5 text-xs"
title="Marquer comme résolu"
>
<div className="flex items-center gap-1.5">
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <CheckCircle className="w-3 h-3" />}
Résoudre
</div>
</button>
<button
onClick={() => handleAction('DISMISSED')}
disabled={isPending}
className="btn-unverify px-3 py-1.5 text-xs bg-slate-700 hover:bg-slate-600 border-none"
title="Rejeter le signalement"
>
<div className="flex items-center gap-1.5">
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
Rejeter
</div>
</button>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import React, { useMemo, useRef } from 'react';
import dynamic from 'next/dynamic';
import 'react-quill-new/dist/quill.snow.css';
// We need to import Quill for registration
const ReactQuill = dynamic(async () => {
const { default: RQ, Quill } = await import("react-quill-new");
// Register custom Divider Blot
if (Quill) {
const BlockEmbed = Quill.import('blots/block/embed') as any;
class DividerBlot extends BlockEmbed {
static blotName = 'divider';
static tagName = 'hr';
}
Quill.register(DividerBlot);
}
return RQ;
}, {
ssr: false,
loading: () => <div className="h-64 w-full bg-slate-900 animate-pulse rounded-lg border border-slate-700" />
}) as any;
interface RichTextEditorProps {
value: string;
onChange: (content: string) => void;
placeholder?: string;
}
export default function RichTextEditor({ value, onChange, placeholder }: RichTextEditorProps) {
const quillRef = useRef<any>(null);
const modules = useMemo(() => ({
toolbar: {
container: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['divider', 'link', 'clean'],
],
handlers: {
divider: function() {
const quill = (quillRef.current as any)?.getEditor();
if (quill) {
const range = quill.getSelection(true);
quill.insertEmbed(range.index, 'divider', true);
quill.setSelection(range.index + 1);
}
}
}
}
}), []);
const formats = [
'header',
'bold', 'italic', 'underline', 'strike',
'list',
'divider', 'link',
];
return (
<div className="quill-dark-wrapper">
<ReactQuill
ref={quillRef}
theme="snow"
value={value}
onChange={onChange}
modules={modules}
formats={formats}
placeholder={placeholder}
className="bg-slate-900 text-white rounded-lg overflow-hidden border border-slate-700 min-h-[300px]"
/>
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Users,
MessageSquare,
BookOpen,
LogOut,
ShieldCheck,
BarChart3,
Flag,
CreditCard,
Settings,
Globe,
FileText,
Briefcase,
Tags,
Image as ImageIcon
} from 'lucide-react';
const menuItems = [
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
{ name: 'Utilisateurs', icon: Users, href: '/users' },
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
{ name: 'Modération', icon: Flag, href: '/moderation' },
{ name: 'Actualités & Interviews', icon: BookOpen, href: '/actualites' },
{ name: 'Forfaits & Services', icon: CreditCard, href: '/plans' },
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
{ name: 'Pays', icon: Globe, href: '/countries' },
{ name: 'Bannière', icon: ImageIcon, href: '/slides' },
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
{ name: 'Configuration', icon: Settings, href: '/settings' },
];
import { useState, useEffect } from 'react';
import { getPendingVerificationCount } from '@/app/actions/business';
export default function Sidebar() {
const pathname = usePathname();
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
getPendingVerificationCount().then(setPendingCount);
}, [pathname]); // Refresh on navigation
return (
<div className="admin-sidebar p-6 flex flex-col">
<div className="flex items-center gap-3 mb-10 px-2">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
<ShieldCheck className="text-white" />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">AfroAdmin</h1>
</div>
<nav className="flex-1 space-y-1">
{menuItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`nav-link ${isActive ? 'active' : ''} flex items-center justify-between group`}
>
<div className="flex items-center">
<item.icon className="w-5 h-5 mr-3" />
<span>{item.name}</span>
</div>
{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">
{pendingCount}
</span>
)}
</Link>
);
})}
</nav>
<div className="mt-auto pt-6 border-t border-slate-700">
<button className="nav-link w-full text-left text-red-400 hover:bg-red-950/30">
<LogOut className="w-5 h-5 mr-3" />
<span>Déconnexion</span>
</button>
</div>
</div>
);
}

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