From 3e2063e4fa234866dc60a73bdb1798806e9317a4 Mon Sep 17 00:00:00 2001 From: streaper2 Date: Sat, 18 Apr 2026 22:10:19 +0200 Subject: [PATCH] feat: implement core platform features including business directory, admin dashboard, authentication, and API infrastructure --- .env | 2 +- admin/prisma/schema.prisma | 320 +++++---- admin/src/app/actions/business.ts | 18 + admin/src/app/actions/countries.ts | 60 ++ admin/src/app/actions/legal.ts | 31 + admin/src/app/actions/moderation.ts | 28 + admin/src/app/actions/plans.ts | 54 ++ admin/src/app/actions/settings.ts | 6 +- admin/src/app/countries/page.tsx | 159 +++++ admin/src/app/dashboard/page.tsx | 9 +- admin/src/app/entrepreneurs/page.tsx | 32 +- admin/src/app/layout.tsx | 4 +- admin/src/app/legal/page.tsx | 24 + admin/src/app/metrics/page.tsx | 428 ++++++++---- admin/src/app/moderation/page.tsx | 270 ++++---- admin/src/app/plans/page.tsx | 103 +++ admin/src/components/FeaturedModal.tsx | 4 +- admin/src/components/LegalEditor.tsx | 131 ++++ admin/src/components/PlanEditModal.tsx | 217 ++++++ admin/src/components/PlanSelector.tsx | 69 ++ .../components/RatingModerationButtons.tsx | 51 ++ admin/src/components/Sidebar.tsx | 30 +- admin/src/lib/prisma.ts | 8 +- admin/test-pricing.ts | 17 + app/afrolife/page.tsx | 8 +- app/annuaire/[id]/page.tsx | 634 ++++++++++++++++-- app/annuaire/page.tsx | 51 +- app/api/admin/reports/route.ts | 2 +- app/api/analytics/businesses/[id]/route.ts | 47 +- app/api/analytics/route.ts | 36 +- app/api/auth/login/route.ts | 2 +- app/api/auth/register/route.ts | 2 +- app/api/blog/route.ts | 2 +- app/api/businesses/[id]/rate/route.ts | 115 ++++ app/api/businesses/[id]/rating/me/route.ts | 49 ++ app/api/businesses/[id]/ratings/route.ts | 55 ++ app/api/businesses/[id]/route.ts | 40 +- app/api/businesses/[id]/view/route.ts | 28 +- app/api/businesses/me/route.ts | 2 +- app/api/businesses/route.ts | 36 +- app/api/conversations/route.ts | 2 +- app/api/countries/route.ts | 15 + app/api/gemini/route.ts | 2 +- app/api/interviews/route.ts | 2 +- app/api/messages/route.ts | 2 +- app/api/messages/unread/route.ts | 2 +- app/api/offers/route.ts | 40 +- app/api/plans/route.ts | 14 + app/api/ratings/[id]/reply/route.ts | 62 ++ app/api/users/me/route.ts | 2 +- app/api/users/profile/route.ts | 2 +- app/api/users/route.ts | 2 +- app/blog/page.tsx | 8 +- app/cgu/page.tsx | 81 +-- app/cgv/page.tsx | 81 +-- app/dashboard/page.tsx | 15 +- app/layout.tsx | 6 +- app/page.tsx | 8 +- app/register/page.tsx | 2 +- app/suspended/page.tsx | 2 +- components/AnalyticsTracker.tsx | 12 + components/BusinessCard.tsx | 2 +- components/CookieBanner.tsx | 2 +- components/Footer.tsx | 46 +- components/Navbar.tsx | 2 +- components/PricingSection.tsx | 177 +++-- components/dashboard/DashboardOffers.tsx | 32 +- components/dashboard/DashboardOverview.tsx | 154 ++++- components/dashboard/DashboardProfile.tsx | 128 +++- docker-compose.yml | 10 +- lib/geo.ts | 44 ++ lib/legal.ts | 12 + lib/settings.ts | 7 +- next-env.d.ts | 2 +- package-lock.json | 524 +++++++++++---- package.json | 7 +- prisma.config.js | 8 +- prisma/schema.prisma | 107 ++- prisma/seed-30.ts | 123 ++++ prisma/seed-plans.ts | 97 +++ prisma/seed.ts | 4 +- scratch/check_db.ts | 23 + scratch/fix_db.ts | 53 ++ scratch/seed_legal.ts | 66 ++ scratch/seed_stats.ts | 71 ++ scratch/test-geoip.ts | 22 + scratch/update_settings.ts | 44 ++ test-analytics.js | 32 +- types.ts | 27 +- 89 files changed, 4405 insertions(+), 967 deletions(-) create mode 100644 admin/src/app/actions/countries.ts create mode 100644 admin/src/app/actions/legal.ts create mode 100644 admin/src/app/actions/plans.ts create mode 100644 admin/src/app/countries/page.tsx create mode 100644 admin/src/app/legal/page.tsx create mode 100644 admin/src/app/plans/page.tsx create mode 100644 admin/src/components/LegalEditor.tsx create mode 100644 admin/src/components/PlanEditModal.tsx create mode 100644 admin/src/components/PlanSelector.tsx create mode 100644 admin/src/components/RatingModerationButtons.tsx create mode 100644 admin/test-pricing.ts create mode 100644 app/api/businesses/[id]/rate/route.ts create mode 100644 app/api/businesses/[id]/rating/me/route.ts create mode 100644 app/api/businesses/[id]/ratings/route.ts create mode 100644 app/api/countries/route.ts create mode 100644 app/api/plans/route.ts create mode 100644 app/api/ratings/[id]/reply/route.ts create mode 100644 lib/geo.ts create mode 100644 lib/legal.ts create mode 100644 prisma/seed-30.ts create mode 100644 prisma/seed-plans.ts create mode 100644 scratch/check_db.ts create mode 100644 scratch/fix_db.ts create mode 100644 scratch/seed_legal.ts create mode 100644 scratch/seed_stats.ts create mode 100644 scratch/test-geoip.ts create mode 100644 scratch/update_settings.ts diff --git a/.env b/.env index ee287be..31f1d76 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ # Connexion locale via le Docker Compose fourni : -DATABASE_URL="postgresql://admin:adminpassword@localhost:5432/afropreunariat_dev?schema=public" +DATABASE_URL="postgresql://admin:adminpassword@localhost: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" \ No newline at end of file diff --git a/admin/prisma/schema.prisma b/admin/prisma/schema.prisma index e5601e4..42716e7 100644 --- a/admin/prisma/schema.prisma +++ b/admin/prisma/schema.prisma @@ -5,90 +5,104 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") -} - -enum UserRole { - VISITOR - ENTREPRENEUR - ADMIN } model User { - id String @id @default(uuid()) - name String - email String @unique - password String - role UserRole @default(VISITOR) - avatar String? - phone String? - bio String? @db.Text - location String? - isSuspended Boolean @default(false) - suspensionReason String? - - businesses Business? - comments Comment[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Messaging relations - sentMessages Message[] - conversations ConversationParticipant[] - reports MessageReport[] + id String @id @default(uuid()) + name String + email String @unique + password String + role UserRole @default(VISITOR) + avatar String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + bio String? + location String? + countryId String? + phone String? + isSuspended Boolean @default(false) + suspensionReason String? + businesses Business? + comments Comment[] + conversations ConversationParticipant[] + sentMessages Message[] + reports MessageReport[] + ratings Rating[] + country Country? @relation(fields: [countryId], references: [id]) } model Business { - id String @id @default(uuid()) - name String - slug String? @unique - category String - location String - description String @db.Text - logoUrl String - videoUrl String? - - // Contact & Social - contactEmail String - contactPhone String? - websiteUrl String? - // We use JSON to store social links (facebook, linkedin, instagram, website) - socialLinks Json? - - // Privacy & Status - showEmail Boolean @default(true) - showPhone Boolean @default(true) - showSocials Boolean @default(true) - isActive Boolean @default(false) - - // Stats - verified Boolean @default(false) - viewCount Int @default(0) - rating Float @default(0.0) - tags String[] - isSuspended Boolean @default(false) - suspensionReason String? - - // Entrepreneur of the Month fields - isFeatured Boolean @default(false) + id String @id @default(uuid()) + name String + category String + location String // Legacy location string + countryId String? + city String? + description String + logoUrl String + videoUrl String? + contactEmail String + contactPhone String? + socialLinks Json? + verified Boolean @default(false) + viewCount Int @default(0) + rating Float @default(0.0) + ratingCount Int @default(0) + tags String[] + isFeatured Boolean @default(false) founderName String? founderImageUrl String? keyMetric String? - - // Relations - ownerId String @unique - owner User @relation(fields: [ownerId], references: [id]) - offers Offer[] - comments Comment[] - conversations Conversation[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + ownerId String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + slug String? @unique + isActive Boolean @default(false) + showEmail Boolean @default(true) + showPhone Boolean @default(true) + showSocials Boolean @default(true) + websiteUrl String? + isSuspended Boolean @default(false) + suspensionReason String? + plan Plan @default(STARTER) + owner User @relation(fields: [ownerId], references: [id]) + comments Comment[] + conversations Conversation[] + offers Offer[] + ratings Rating[] + country Country? @relation(fields: [countryId], references: [id]) } -enum OfferType { - PRODUCT - SERVICE +model Country { + id String @id @default(uuid()) + name String @unique + code String @unique // ISO alpha-2 + flag String? // Emoji or URL + isActive Boolean @default(true) + businesses Business[] + users User[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum Plan { + STARTER + BOOSTER + EMPIRE +} + +model PricingPlan { + id String @id @default(uuid()) + tier Plan @unique + name String + priceXOF String + priceEUR String + description String + features String[] + offerLimit Int @default(1) + recommended Boolean @default(false) + color String @default("gray") + updatedAt DateTime @updatedAt } model Offer { @@ -96,52 +110,28 @@ model Offer { title String type OfferType price Float - currency String @default("XOF") // EUR or XOF - description String? @db.Text + currency String @default("XOF") + description String? imageUrl String active Boolean @default(true) - - // Relations businessId String - business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) - 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 @db.Text - content String @db.Text - author String + excerpt String + content String + author String date DateTime @default(now()) imageUrl String - createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } -enum InterviewType { - VIDEO - ARTICLE -} - -model SiteSetting { - id String @id @default("singleton") - siteName String @default("Afropreunariat") - siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.") - contactEmail String @default("support@afropreunariat.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 Afropreunariat. Tous droits réservés.") - updatedAt DateTime @updatedAt -} - model Interview { id String @id @default(uuid()) title String @@ -151,73 +141,97 @@ model Interview { type InterviewType thumbnailUrl String videoUrl String? - content String? @db.Text - excerpt String @db.Text + content String? + excerpt String date DateTime @default(now()) - duration String? // "15 min" ou "5 min de lecture" - + duration String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Comment { - id String @id @default(uuid()) - content String @db.Text - - // Relations - authorId String - author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + content String + authorId String businessId String - business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + 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 // PAGE_VIEW, CLICK, DWELL_TIME + type String path String - label String? // For clicks (button text, link name) - value Float? // For dwell time (seconds) - metadata Json? // Browser, OS, etc. + label String? + value Float? + metadata Json? + ip String? + country String? + city String? + browser String? + os String? + device String? + referrer String? + userId String? createdAt DateTime @default(now()) } -model Conversation { +model Rating { id String @id @default(uuid()) + value Int + comment String? + reply String? + replyAt DateTime? + status RatingStatus @default(PENDING) + userId String businessId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + @@unique([userId, businessId]) +} + +enum RatingStatus { + PENDING + APPROVED + REJECTED +} + +model Conversation { + id String @id @default(uuid()) + businessId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) participants ConversationParticipant[] messages Message[] } model ConversationParticipant { - id String @id @default(uuid()) + id String @id @default(uuid()) userId String conversationId String - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) - isArchived Boolean @default(false) + 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 @db.Text + id String @id @default(uuid()) + content String senderId String conversationId String - createdAt DateTime @default(now()) - read Boolean @default(false) - - sender User @relation(fields: [senderId], references: [id], onDelete: Cascade) - conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) - reports MessageReport[] + 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 { @@ -227,9 +241,8 @@ model MessageReport { 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) + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) + reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade) } enum ReportStatus { @@ -237,3 +250,34 @@ enum ReportStatus { RESOLVED DISMISSED } + +enum UserRole { + VISITOR + ENTREPRENEUR + ADMIN +} + +enum OfferType { + PRODUCT + SERVICE +} + +enum InterviewType { + VIDEO + ARTICLE +} + +model SiteSetting { + id String @id @default("singleton") + siteName String @default("Afrohub") + siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.") + contactEmail String @default("support@afrohub.com") + contactPhone String? @default("+225 00 00 00 00 00") + address String? @default("Abidjan, Côte d'Ivoire") + facebookUrl String? + twitterUrl String? + instagramUrl String? + linkedinUrl String? + footerText String? @default("© 2025 Afrohub. Tous droits réservés.") + updatedAt DateTime @updatedAt +} diff --git a/admin/src/app/actions/business.ts b/admin/src/app/actions/business.ts index 7cad440..ecc2017 100644 --- a/admin/src/app/actions/business.ts +++ b/admin/src/app/actions/business.ts @@ -61,3 +61,21 @@ export async function removeFeaturedBusiness(id: string) { 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; + } +} diff --git a/admin/src/app/actions/countries.ts b/admin/src/app/actions/countries.ts new file mode 100644 index 0000000..31712d3 --- /dev/null +++ b/admin/src/app/actions/countries.ts @@ -0,0 +1,60 @@ +"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 }) { + try { + const country = await prisma.country.create({ + data: { + name: data.name, + code: data.code.toUpperCase(), + flag: data.flag || 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 }; + } +} diff --git a/admin/src/app/actions/legal.ts b/admin/src/app/actions/legal.ts new file mode 100644 index 0000000..0903565 --- /dev/null +++ b/admin/src/app/actions/legal.ts @@ -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" }; + } +} diff --git a/admin/src/app/actions/moderation.ts b/admin/src/app/actions/moderation.ts index 2db7df2..ac8e842 100644 --- a/admin/src/app/actions/moderation.ts +++ b/admin/src/app/actions/moderation.ts @@ -50,3 +50,31 @@ export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMI 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" }; + } +} diff --git a/admin/src/app/actions/plans.ts b/admin/src/app/actions/plans.ts new file mode 100644 index 0000000..5e2d109 --- /dev/null +++ b/admin/src/app/actions/plans.ts @@ -0,0 +1,54 @@ +"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, + priceEUR: data.priceEUR, + 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.' }; + } +} diff --git a/admin/src/app/actions/settings.ts b/admin/src/app/actions/settings.ts index 75c3091..efcb5d9 100644 --- a/admin/src/app/actions/settings.ts +++ b/admin/src/app/actions/settings.ts @@ -11,16 +11,16 @@ export async function getSiteSettings() { // Default fallback values if not initialized const defaultSettings = { - siteName: "Afropreunariat", + siteName: "Afrohub", siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain.", - contactEmail: "support@afropreunariat.com", + contactEmail: "support@afrohub.com", contactPhone: "+225 00 00 00 00 00", address: "Abidjan, Côte d'Ivoire", facebookUrl: "", twitterUrl: "", instagramUrl: "", linkedinUrl: "", - footerText: "© 2025 Afropreunariat. Tous droits réservés." + footerText: "© 2025 Afrohub. Tous droits réservés." }; if (!settings) return defaultSettings; diff --git a/admin/src/app/countries/page.tsx b/admin/src/app/countries/page.tsx new file mode 100644 index 0000000..15882b4 --- /dev/null +++ b/admin/src/app/countries/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import React, { useState, useEffect, useTransition } from 'react'; +import { getCountries, addCountry, deleteCountry, toggleCountryStatus } from '@/app/actions/countries'; +import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag } from 'lucide-react'; +import { toast } from 'react-hot-toast'; + +export default function CountriesPage() { + const [countries, setCountries] = useState([]); + const [loading, setLoading] = useState(true); + const [isAdding, setIsAdding] = useState(false); + const [isPending, startTransition] = useTransition(); + + const loadData = async () => { + const data = await getCountries(); + setCountries(data); + setLoading(false); + }; + + useEffect(() => { + loadData(); + }, []); + + const handleAdd = async (e: React.FormEvent) => { + 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; + + startTransition(async () => { + const result = await addCountry({ name, code, flag }); + 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) { + loadData(); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Gestion des Pays

+

Gérez les pays disponibles pour les entreprises et le filtrage du répertoire.

+
+ +
+ + {isAdding && ( +
+

Nouveau Pays

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} + +
+ + + + + + + + + + + {countries.length === 0 ? ( + + + + ) : countries.map((country) => ( + + + + + + + ))} + +
PaysCodeStatusActions
Aucun pays configuré.
+
+ {country.flag || } + {country.name} +
+
{country.code} + + + +
+
+
+ ); +} diff --git a/admin/src/app/dashboard/page.tsx b/admin/src/app/dashboard/page.tsx index a4c54ad..3e68c1d 100644 --- a/admin/src/app/dashboard/page.tsx +++ b/admin/src/app/dashboard/page.tsx @@ -21,7 +21,13 @@ async function getStats() { }); const totalViews = aggregateResult._sum.viewCount || 0; - return { usersCount, businessCount, pendingCount, commentsCount, totalViews }; + // 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() { @@ -29,6 +35,7 @@ export default async function DashboardPage() { 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' }, diff --git a/admin/src/app/entrepreneurs/page.tsx b/admin/src/app/entrepreneurs/page.tsx index f1be11c..6a1851e 100644 --- a/admin/src/app/entrepreneurs/page.tsx +++ b/admin/src/app/entrepreneurs/page.tsx @@ -2,7 +2,8 @@ import { prisma } from '@/lib/prisma'; import ToggleVerifyButton from '@/components/ToggleVerifyButton'; import FeaturedModal from '@/components/FeaturedModal'; import EntrepreneurActions from '@/components/EntrepreneurActions'; -import { ShieldAlert, ShieldX } from 'lucide-react'; +import PlanSelector from '@/components/PlanSelector'; +import { ShieldAlert, ShieldX, ShieldCheck } from 'lucide-react'; async function getBusinesses() { return await prisma.business.findMany({ @@ -20,12 +21,31 @@ async function getBusinesses() { export default async function EntrepreneursPage() { const businesses = await getBusinesses(); + const pendingCount = businesses.filter((b: any) => + !b.verified && + !b.isSuspended && + ['BOOSTER', 'EMPIRE'].includes(b.plan) + ).length; return (
-
-

Gestion des Entrepreneurs

-

Valider ou révoquer les comptes des entreprises.

+
+
+

Gestion des Entrepreneurs

+

Gérer les abonnements et certifier les comptes (Badge Bleu).

+
+ + {pendingCount > 0 && ( +
+
+ +
+
+
{pendingCount} en attente
+
Certifications Booster
+
+
+ )}
@@ -36,6 +56,7 @@ export default async function EntrepreneursPage() { Catégorie Propriétaire Statut + Forfait Visibilité Mise en avant Action @@ -90,6 +111,9 @@ export default async function EntrepreneursPage() { )}
+ + + {business.viewCount.toLocaleString()} diff --git a/admin/src/app/layout.tsx b/admin/src/app/layout.tsx index 67642f7..74ce6b3 100644 --- a/admin/src/app/layout.tsx +++ b/admin/src/app/layout.tsx @@ -7,8 +7,8 @@ import { Toaster } from 'react-hot-toast'; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "AfroAdmin - Administration Afropreunariat", - description: "Panneau d'administration pour la plateforme Afropreunariat", + title: "AfroAdmin - Administration Afrohub", + description: "Panneau d'administration pour la plateforme Afrohub", }; export default function RootLayout({ diff --git a/admin/src/app/legal/page.tsx b/admin/src/app/legal/page.tsx new file mode 100644 index 0000000..f617fc5 --- /dev/null +++ b/admin/src/app/legal/page.tsx @@ -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 ( +
+
+

Documents Légaux

+

Modifiez les conditions générales d'utilisation et de vente du site.

+
+ + +
+ ); +} diff --git a/admin/src/app/metrics/page.tsx b/admin/src/app/metrics/page.tsx index 715a332..ab4a202 100644 --- a/admin/src/app/metrics/page.tsx +++ b/admin/src/app/metrics/page.tsx @@ -1,125 +1,341 @@ import { prisma } from '@/lib/prisma'; +import Link from 'next/link'; import { BarChart3, MousePointer2, Clock, Eye, ArrowUpRight, - TrendingUp + TrendingUp, + Globe, + Users, + Zap, + Search, + ArrowUp, + ArrowDown, + Calendar } from 'lucide-react'; -async function getMetrics() { - // 1. Top Pages - const topPages = await prisma.analyticsEvent.groupBy({ - by: ['path'], - where: { type: 'PAGE_VIEW' }, - _count: { - id: true - }, - orderBy: { - _count: { - id: 'desc' - } - }, - take: 10 - }); +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; - // 2. Top Clicks - const topClicks = await prisma.analyticsEvent.groupBy({ - by: ['label'], - where: { type: 'CLICK' }, - _count: { - id: true - }, - orderBy: { - _count: { - id: 'desc' - } - }, - take: 10 - }); + if (isZero) return ( + + = 0% + + ); - // 3. Average Dwell Time - const dwellTime = await prisma.analyticsEvent.groupBy({ - by: ['path'], - where: { type: 'DWELL_TIME' }, - _avg: { - value: true - }, - _count: { - id: true - }, - orderBy: { - _avg: { - value: 'desc' - } - }, - take: 10 - }); - - // 4. Global Stats - const totalEvents = await prisma.analyticsEvent.count(); - const uniquePaths = (await prisma.analyticsEvent.groupBy({ by: ['path'] })).length; - - return { topPages, topClicks, dwellTime, totalEvents, uniquePaths }; + return ( + + {isPositive ? : } + {Math.abs(value).toFixed(1)}{isPercent ? '%' : ''} + + ); } -export default async function MetricsPage() { - const { topPages, topClicks, dwellTime, totalEvents, uniquePaths } = await getMetrics(); +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; + } + + const currentMobileCount = devices.find(d => d.device === 'Mobile')?._count.id || 0; + + return { + topPages, + topCountries, + devices, + browsers, + totalEvents, + uniqueVisitors, + 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, deltas } = await getMetrics(range, searchParams.from, searchParams.to); + + const rangeLabels: Record = { + day: 'Dernières 24h', + week: '7 derniers jours', + month: '30 derniers jours', + all: 'Depuis le début' + }; return (

Metrics & Analytics

-

Comportement des utilisateurs et engagement en temps réel.

+

Comportement des utilisateurs et engagement géographique en temps réel.

Capture : {totalEvents.toLocaleString()} événements total
+ {/* Controls: Shortcuts + Custom Calendar */} +
+
+ {Object.entries(rangeLabels).map(([id, label]) => ( + + {label} + + ))} +
+ +
+ + + + + + +
+ {/* Summary Cards */}
-
+
+ +
+
+
{uniqueVisitors}
+ +
+
+

Visiteurs uniques (IP)

+

+ {searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]} +

+
+ +
+
+
- {uniquePaths} -
-

Pages uniques explorées

-
-
-
-
- +
+
{totalEvents}
+
- - {topClicks.reduce((acc, curr) => acc + curr._count.id, 0)} -
-

Clicks enregistrés (Top 10)

+

Événements enregistrés

+

+ {searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]} +

-
+ +
- + +
+
+
+ {totalEvents > 0 ? Math.round((devices.find(d => d.device === 'Mobile')?._count.id || 0) / totalEvents * 100) : 0}% +
+
- - {Math.round(dwellTime.reduce((acc, curr) => acc + (curr._avg.value || 0), 0) / (dwellTime.length || 1))}s -
-

Temps moyen / page

+

Part du trafic Mobile

+

+ {searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]} +

- {/* Top Pages Table */} + {/* Top Countries Table */}

- Pages les plus visitées + TOP 10 - Géographie +

+
+ + + + + + + + + {topCountries.map((c) => ( + + + + + ))} + +
PaysVisites
{c.country || 'Inconnu'} + {c._count.id} +
+
+ + {/* Device & Browser Stats */} +
+
+
+

+ Appareils +

+
+
+ {devices.map(d => ( +
+
+ {d.device || 'Autre'} + {Math.round(d._count.id / totalEvents * 100)}% +
+
+
+
+
+ ))} +
+
+ +
+
+

+ Navigateurs +

+
+
+ {browsers.map(b => ( +
+
{b._count.id}
+
{b.browser || 'Autre'}
+
+ ))} +
+
+
+ + {/* Top Pages Table (Moved and compact) */} +
+
+

+ Pages les plus visitées

@@ -141,64 +357,6 @@ export default async function MetricsPage() {
- - {/* Top Clicks Table */} -
-
-

- Actions & Clicks -

-
- - - - - - - - - {topClicks.map((click) => ( - - - - - ))} - -
Élément / TexteInteractions
{click.label || 'Sans label'} - {click._count.id} -
-
- - {/* Dwell Time Table */} -
-
-

- Temps d'attention par page -

-
- - - - - - - - - - {dwellTime.map((time) => ( - - - - - - ))} - -
PageÉchantillonMoyenne de rétention
{time.path}{time._count.id} sessions - - {Math.round(time._avg.value || 0)} secondes - -
-
); diff --git a/admin/src/app/moderation/page.tsx b/admin/src/app/moderation/page.tsx index 03167a3..21052cd 100644 --- a/admin/src/app/moderation/page.tsx +++ b/admin/src/app/moderation/page.tsx @@ -1,133 +1,179 @@ -import { getReports } from '@/app/actions/moderation'; +import { getReports, getPendingRatings } from '@/app/actions/moderation'; import ReportActionButtons from '@/components/ReportActionButtons'; import ModerationSuspensionButton from '@/components/ModerationSuspensionButton'; -import { Flag, MessageCircle, User, Calendar } from 'lucide-react'; +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() { +export default async function ModerationPage({ + searchParams +}: { + searchParams: Promise<{ tab?: string }> +}) { + const { tab = 'messages' } = await searchParams; const reports = await getReports(); + const pendingRatings = await getPendingRatings(); return (

- Modération des Messages + Centre de Modération

-

Gérer les signalements de contenus inappropriés dans les discussions.

+

Gérer les signalements et valider les nouveaux avis.

+
+ + {/* Tabs */} +
+ + + Messages ({reports.filter(r => r.status === 'PENDING').length}) + + + + Avis ({pendingRatings.length}) +
- {reports.length === 0 ? ( -
- Aucun signalement en attente. -
- ) : ( - reports.map((report) => ( -
-
- {/* 1. Report Info */} -
-
- - {new Date(report.createdAt).toLocaleString('fr-FR')} -
- -
-
Signalé par
-
-
- {report.reporter.name.charAt(0)} -
-
-
{report.reporter.name}
-
{report.reporter.email}
-
-
-
- -
-
Raison invoquée
-

"{report.reason}"

-
- -
- - {report.status === 'PENDING' ? 'En attente' : - report.status === 'RESOLVED' ? 'Résolu' : - 'Rejeté'} - -
+ {tab === 'messages' && ( + <> + {reports.length === 0 ? ( +
+ Aucun signalement en attente.
- - {/* 2. Conversation History */} -
-
-
- - Historique de la discussion -
- {report.message.conversation.business && ( -
- Shop: {report.message.conversation.business.name} -
- )} -
- -
- {report.message.conversation.messages.map((msg: any) => { - const isReported = msg.id === report.messageId; - const senderName = msg.sender.name; - - return ( -
-
- - {senderName} - - - {new Date(msg.createdAt).toLocaleString('fr-FR', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' })} - -
-
- {msg.content} -
- {isReported && ( -
- - Message Signalé + ) : ( + reports.map((report) => ( +
+
+
+
+ + {new Date(report.createdAt).toLocaleString('fr-FR')} +
+
+
Signalé par
+
+
{report.reporter.name.charAt(0)}
+
+
{report.reporter.name}
+
{report.reporter.email}
+
+
+
+
+
Raison
+

"{report.reason}"

+
+
+ + {report.status === 'PENDING' ? 'En attente' : 'Terminé'} + +
+
+
+ + Voir la conversation complète → + +
+
Message Signalé
+
"{report.message.content}"
+
Auteur: {report.message.sender.name}
+
+ {report.status === 'PENDING' && ( +
+ + +
+ )}
- )}
- ); - })} -
- - {report.status === 'PENDING' && ( -
-
-
-
Résoudre le signalement
- -
-
-
Sanctionner l'auteur
- -
-
- )} -
-
-
- )) + )) + )} + + )} + + {tab === 'avis' && ( + <> + {pendingRatings.length === 0 ? ( +
+ +

Tous les avis sont modérés

+

Beau travail ! Il n'y a plus aucun avis en attente.

+
+ ) : ( + pendingRatings.map((r) => ( +
+
+
+
+ + {new Date(r.createdAt).toLocaleString('fr-FR')} +
+ +
+
Auteur
+
+
{r.user.name.charAt(0)}
+
+
{r.user.name}
+
{r.user.email}
+
+
+
+ +
+
Entrepreneur Concerné
+
+
{r.business.name}
+
+
+
+ +
+
+
+
+ {[1,2,3,4,5].map(s => ( + + ))} +
+

+ {r.comment || "Pas de commentaire."} +

+
+ +
+
+ + En attente de validation +
+ +
+
+
+
+
+ )) + )} + )}
diff --git a/admin/src/app/plans/page.tsx b/admin/src/app/plans/page.tsx new file mode 100644 index 0000000..b047122 --- /dev/null +++ b/admin/src/app/plans/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import { getPricingPlans } from '@/app/actions/plans'; +import { CreditCard, Edit3, CheckCircle2, Star, Loader2, Zap } from 'lucide-react'; +import PlanEditModal from '@/components/PlanEditModal'; + +export default function PlansPage() { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [editingPlan, setEditingPlan] = useState(null); + + useEffect(() => { + async function loadPlans() { + const data = await getPricingPlans(); + setPlans(data); + setLoading(false); + } + loadPlans(); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Gestion des Tarifs & Forfaits

+

Modifiez les noms, prix, limites d'offres et avantages affichés sur le site principal.

+
+ +
+ {plans.map((plan) => ( +
+ {/* Header */} +
+
+
+ {plan.tier === 'STARTER' ? : + plan.tier === 'BOOSTER' ? : + } +
+ {plan.recommended && ( + + Populaire + + )} +
+

{plan.name}

+

{plan.description}

+
+ + {/* Price & Limit */} +
+
+
{plan.priceXOF}
+
Limite : {plan.offerLimit} Offre(s)
+
+ +
+
Avantages inclus :
+ {plan.features.map((feature: string, idx: number) => ( +
+ + {feature} +
+ ))} +
+
+ + {/* Action */} +
+ +
+
+ ))} +
+ + {editingPlan && ( + setEditingPlan(null)} + plan={editingPlan} + /> + )} +
+ ); +} diff --git a/admin/src/components/FeaturedModal.tsx b/admin/src/components/FeaturedModal.tsx index 268ff9b..cf1ae40 100644 --- a/admin/src/components/FeaturedModal.tsx +++ b/admin/src/components/FeaturedModal.tsx @@ -40,7 +40,7 @@ export default function FeaturedModal({ business }: Props) { }); }; - const handleRemove = () => { + const handleRemove = async () => { if (confirm("Retirer cet entrepreneur du titre 'Entrepreneur du mois' ?")) { startTransition(async () => { const result = await removeFeaturedBusiness(business.id); @@ -48,7 +48,7 @@ export default function FeaturedModal({ business }: Props) { toast.success("Titre révoqué"); setIsOpen(false); } else { - toast.error(result.error); + toast.error(result.error || "Une erreur est survenue lors de la suppression"); } }); } diff --git a/admin/src/components/LegalEditor.tsx b/admin/src/components/LegalEditor.tsx new file mode 100644 index 0000000..386eea7 --- /dev/null +++ b/admin/src/components/LegalEditor.tsx @@ -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 ( +
+
+ + +
+ +
+
+
+
+ +
+
+

Modifier {activeTab}

+

Dernière mise à jour : {new Date().toLocaleDateString('fr-FR')}

+
+
+ + +
+ +
+
+ + 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" + /> +
+ +
+ + handleUpdate('content', val)} + placeholder={`Commencez à rédiger vos ${activeTab}...`} + /> +
+
+
+ +
+
!
+

+ Attention : Les modifications seront immédiatement visibles pour tous les utilisateurs sur le site public une fois enregistrées. +

+
+
+ ); +} diff --git a/admin/src/components/PlanEditModal.tsx b/admin/src/components/PlanEditModal.tsx new file mode 100644 index 0000000..859b08c --- /dev/null +++ b/admin/src/components/PlanEditModal.tsx @@ -0,0 +1,217 @@ +"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; +} + +export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalProps) { + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + name: plan.name, + priceXOF: plan.priceXOF, + priceEUR: plan.priceEUR, + description: plan.description, + offerLimit: plan.offerLimit.toString(), + recommended: plan.recommended, + color: plan.color, + }); + const [features, setFeatures] = useState(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 ( +
+
+ +
+ {/* Header */} +
+
+

Modifier le plan {plan.tier}

+

Configurez l'affichage et les limites de ce forfait.

+
+ +
+ + {/* Content */} +
+
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ +