From 9846efd03eadc81a0ca2c269edd28dd7862bb856 Mon Sep 17 00:00:00 2001 From: streaper2 Date: Sun, 10 May 2026 17:50:56 +0200 Subject: [PATCH] feat: implement comprehensive CMS dashboard for managing news, events, interviews, and one-shot pricing plans --- admin/prisma/schema.prisma | 49 +++- .../app/actions/{blog.ts => actualites.ts} | 9 +- admin/src/app/actions/approval.ts | 64 +++++ admin/src/app/actions/one-shot.ts | 40 +++ admin/src/app/actions/slides.ts | 2 + .../{blog => actualites}/edit/[id]/page.tsx | 6 +- admin/src/app/actualites/new/page.tsx | 5 + admin/src/app/{blog => actualites}/page.tsx | 89 ++++++- admin/src/app/blog/new/page.tsx | 5 - admin/src/app/plans/page.tsx | 27 +- admin/src/app/settings/page.tsx | 203 +++++++------- admin/src/app/slides/edit/[id]/page.tsx | 6 +- .../{BlogForm.tsx => ActualitesForm.tsx} | 6 +- admin/src/components/ApproveContentButton.tsx | 61 +++++ ...gButton.tsx => DeleteActualitesButton.tsx} | 4 +- admin/src/components/EventForm.tsx | 4 +- admin/src/components/ImageUploader.tsx | 4 +- admin/src/components/InterviewForm.tsx | 4 +- .../src/components/OneShotPricingSettings.tsx | 183 +++++++++++++ admin/src/components/Sidebar.tsx | 4 +- admin/src/components/SlideForm.tsx | 17 +- admin/src/lib/prisma.ts | 8 +- app/HomeClient.tsx | 6 +- app/actions/events.ts | 42 +++ app/actions/news.ts | 40 +++ app/{blog => actualites}/[id]/page.tsx | 6 +- app/{blog => actualites}/page.tsx | 6 +- app/api/one-shot-plans/route.ts | 14 + app/dashboard/page.tsx | 4 +- app/subscription/page.tsx | 2 + components/Footer.tsx | 2 +- components/HomeSlider.tsx | 2 +- components/Navbar.tsx | 6 +- components/OneShotPricingSection.tsx | 251 ++++++++++++++++++ components/dashboard/CreateEventModal.tsx | 229 ++++++++++++++++ components/dashboard/CreateNewsModal.tsx | 216 +++++++++++++++ lib/prisma.ts | 8 +- package.json | 3 +- prisma/schema.prisma | 18 ++ scratch/debug-prisma.ts | 7 + 40 files changed, 1487 insertions(+), 175 deletions(-) rename admin/src/app/actions/{blog.ts => actualites.ts} (92%) create mode 100644 admin/src/app/actions/approval.ts create mode 100644 admin/src/app/actions/one-shot.ts rename admin/src/app/{blog => actualites}/edit/[id]/page.tsx (52%) create mode 100644 admin/src/app/actualites/new/page.tsx rename admin/src/app/{blog => actualites}/page.tsx (62%) delete mode 100644 admin/src/app/blog/new/page.tsx rename admin/src/components/{BlogForm.tsx => ActualitesForm.tsx} (97%) create mode 100644 admin/src/components/ApproveContentButton.tsx rename admin/src/components/{DeleteBlogButton.tsx => DeleteActualitesButton.tsx} (85%) create mode 100644 admin/src/components/OneShotPricingSettings.tsx create mode 100644 app/actions/events.ts create mode 100644 app/actions/news.ts rename app/{blog => actualites}/[id]/page.tsx (97%) rename app/{blog => actualites}/page.tsx (91%) create mode 100644 app/api/one-shot-plans/route.ts create mode 100644 components/OneShotPricingSection.tsx create mode 100644 components/dashboard/CreateEventModal.tsx create mode 100644 components/dashboard/CreateNewsModal.tsx create mode 100644 scratch/debug-prisma.ts diff --git a/admin/prisma/schema.prisma b/admin/prisma/schema.prisma index ce93b4f..abb5f64 100644 --- a/admin/prisma/schema.prisma +++ b/admin/prisma/schema.prisma @@ -30,11 +30,11 @@ model User { businesses Business? comments Comment[] conversations ConversationParticipant[] + favorites Favorite[] sentMessages Message[] reports MessageReport[] ratings Rating[] country Country? @relation(fields: [countryId], references: [id]) - favorites Favorite[] } model Business { @@ -68,8 +68,6 @@ model Business { websiteUrl String? isSuspended Boolean @default(false) suspensionReason String? - metaTitle String? - metaDescription String? plan Plan @default(STARTER) city String? countryId String? @@ -78,14 +76,17 @@ model Business { 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[] - favorites Favorite[] } model BusinessCategory { @@ -161,6 +162,21 @@ model BlogPost { 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 @@ -301,6 +317,23 @@ model SiteSetting { verifyEmailTemplate String @default("

{siteName}

Bonjour {name},

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 :

Vérifier mon compte

Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.


© 2025 {siteName}. Tous droits réservés.

") } +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 @@ -332,14 +365,20 @@ model Favorite { userId String businessId String createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, businessId]) } +enum HomeSlideType { + BUSINESS + CUSTOM +} + enum ContentStatus { DRAFT + PENDING PUBLISHED ARCHIVED } diff --git a/admin/src/app/actions/blog.ts b/admin/src/app/actions/actualites.ts similarity index 92% rename from admin/src/app/actions/blog.ts rename to admin/src/app/actions/actualites.ts index 32601f5..53ca9d0 100644 --- a/admin/src/app/actions/blog.ts +++ b/admin/src/app/actions/actualites.ts @@ -30,7 +30,8 @@ export async function createBlogPost(data: { status: data.status || ContentStatus.PUBLISHED, }, }); - revalidatePath("/blog"); + revalidatePath("/actualites"); + revalidatePath("/"); return { success: true, id: post.id }; } catch (error) { console.error("Failed to create blog post:", error); @@ -62,7 +63,8 @@ export async function updateBlogPost(id: string, data: { tags: data.tags || [], }, }); - revalidatePath("/blog"); + revalidatePath("/actualites"); + revalidatePath("/"); return { success: true, id: post.id }; } catch (error) { console.error("Failed to update blog post:", error); @@ -75,7 +77,8 @@ export async function deleteBlogPost(id: string) { await prisma.blogPost.delete({ where: { id }, }); - revalidatePath("/blog"); + revalidatePath("/actualites"); + revalidatePath("/"); return { success: true }; } catch (error) { console.error("Failed to delete blog post:", error); diff --git a/admin/src/app/actions/approval.ts b/admin/src/app/actions/approval.ts new file mode 100644 index 0000000..0dcbc0c --- /dev/null +++ b/admin/src/app/actions/approval.ts @@ -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" }; + } +} diff --git a/admin/src/app/actions/one-shot.ts b/admin/src/app/actions/one-shot.ts new file mode 100644 index 0000000..dfaae5f --- /dev/null +++ b/admin/src/app/actions/one-shot.ts @@ -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" }; + } +} diff --git a/admin/src/app/actions/slides.ts b/admin/src/app/actions/slides.ts index 69e7922..5a8dd51 100644 --- a/admin/src/app/actions/slides.ts +++ b/admin/src/app/actions/slides.ts @@ -102,6 +102,8 @@ export async function searchBusinesses(query: string) { id: true, name: true, logoUrl: true, + coverUrl: true, + description: true, location: true, } }); diff --git a/admin/src/app/blog/edit/[id]/page.tsx b/admin/src/app/actualites/edit/[id]/page.tsx similarity index 52% rename from admin/src/app/blog/edit/[id]/page.tsx rename to admin/src/app/actualites/edit/[id]/page.tsx index b2eeed0..37a2740 100644 --- a/admin/src/app/blog/edit/[id]/page.tsx +++ b/admin/src/app/actualites/edit/[id]/page.tsx @@ -1,8 +1,8 @@ import { prisma } from "@/lib/prisma"; -import BlogForm from "@/components/BlogForm"; +import ActualitesForm from "@/components/ActualitesForm"; import { notFound } from "next/navigation"; -export default async function EditBlogPage({ params }: { params: Promise<{ id: string }> }) { +export default async function EditActualitesPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const post = await prisma.blogPost.findUnique({ where: { id }, @@ -12,5 +12,5 @@ export default async function EditBlogPage({ params }: { params: Promise<{ id: s notFound(); } - return ; + return ; } diff --git a/admin/src/app/actualites/new/page.tsx b/admin/src/app/actualites/new/page.tsx new file mode 100644 index 0000000..ecb3820 --- /dev/null +++ b/admin/src/app/actualites/new/page.tsx @@ -0,0 +1,5 @@ +import ActualitesForm from "@/components/ActualitesForm"; + +export default function NewActualitesPage() { + return ; +} diff --git a/admin/src/app/blog/page.tsx b/admin/src/app/actualites/page.tsx similarity index 62% rename from admin/src/app/blog/page.tsx rename to admin/src/app/actualites/page.tsx index 4d1ef79..bfad321 100644 --- a/admin/src/app/blog/page.tsx +++ b/admin/src/app/actualites/page.tsx @@ -1,9 +1,10 @@ import { prisma } from '@/lib/prisma'; import Link from 'next/link'; -import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin } from 'lucide-react'; -import DeleteBlogButton from '@/components/DeleteBlogButton'; +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' } }); @@ -14,25 +15,30 @@ async function getData() { const getStatusBadge = (status: string) => { switch (status) { + case 'PENDING': return EN ATTENTE; case 'DRAFT': return BROUILLON; case 'PUBLISHED': return PUBLIÉ; - case 'ARCHIVED': return ARCHIVÉ; + case 'ARCHIVED': return REJETÉ/ARCHIVÉ; default: return null; } } -export default async function BlogCMSPage() { +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 (
-

CMS Blog, Interviews & Événements

+

CMS Actualités, Interviews & Événements

Gérez le contenu éditorial et événementiel de la plateforme.

- + Nouvel Article @@ -47,12 +53,77 @@ export default async function BlogCMSPage() {
+ {/* PENDING SUBMISSIONS SECTION */} + {hasPending && ( +
+
+
+ +
+
+

Soumissions en attente ({pendingPosts.length + pendingEvents.length})

+

Contenu soumis par les utilisateurs nécessitant une validation.

+
+
+ +
+ {pendingPosts.map(post => ( +
+
+
+ +
+
+
+

{post.title}

+ Article +
+

Par {post.author} • {new Date(post.createdAt).toLocaleDateString('fr-FR')}

+
+
+
+ + + + + +
+
+ ))} + + {pendingEvents.map(event => ( +
+
+
+ +
+
+
+

{event.title}

+ Événement +
+

{new Date(event.date).toLocaleDateString('fr-FR')} • {event.location}

+
+
+
+ + + + + +
+
+ ))} +
+
+ )} +
{/* Blog Posts Section */}
-

Articles de Blog ({posts.length})

+

Articles d'Actualités ({posts.length})

@@ -77,10 +148,10 @@ export default async function BlogCMSPage() { diff --git a/admin/src/app/blog/new/page.tsx b/admin/src/app/blog/new/page.tsx deleted file mode 100644 index 803a5af..0000000 --- a/admin/src/app/blog/new/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import BlogForm from "@/components/BlogForm"; - -export default function NewBlogPage() { - return ; -} diff --git a/admin/src/app/plans/page.tsx b/admin/src/app/plans/page.tsx index a51b69c..ceaca95 100644 --- a/admin/src/app/plans/page.tsx +++ b/admin/src/app/plans/page.tsx @@ -2,20 +2,32 @@ 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([]); + const [oneShotPlans, setOneShotPlans] = useState([]); const [loading, setLoading] = useState(true); const [editingPlan, setEditingPlan] = useState(null); const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly'); useEffect(() => { async function loadPlans() { - const data = await getPricingPlans(); - setPlans(data); - setLoading(false); + 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(); }, []); @@ -32,8 +44,8 @@ export default function PlansPage() {
-

Gestion des Tarifs & Forfaits

-

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

+

Gestion des Forfaits & Services

+

Modifiez les abonnements mensuels et les services ponctuels (One-Shot).

{/* Toggle Billing */} @@ -129,6 +141,11 @@ export default function PlansPage() { currentCycle={billingCycle} /> )} + + {/* One Shot Plans Section */} +
+ +
); } diff --git a/admin/src/app/settings/page.tsx b/admin/src/app/settings/page.tsx index 2221390..7b0be2c 100644 --- a/admin/src/app/settings/page.tsx +++ b/admin/src/app/settings/page.tsx @@ -4,7 +4,7 @@ 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/blog'; +import { getBlogPosts } from '@/app/actions/actualites'; import RichTextEditor from '@/components/RichTextEditor'; const BUSINESS_CATEGORIES = [ @@ -34,10 +34,10 @@ const BLOG_CATEGORIES = [ "Formation" ]; -export default function SettingsPage({ - searchParams -}: { - searchParams: Promise<{ tab?: string }> +export default function SettingsPage({ + searchParams +}: { + searchParams: Promise<{ tab?: string }> }) { const [isPending, startTransition] = useTransition(); const [settings, setSettings] = useState(null); @@ -48,25 +48,31 @@ export default function SettingsPage({ const [selectedBlogCategories, setSelectedBlogCategories] = useState([]); 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() { - 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 || ''); - setLoading(false); + 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(); }, []); @@ -110,19 +116,19 @@ export default function SettingsPage({ }; const togglePostId = (id: string) => { - setSelectedPostIds(prev => + setSelectedPostIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] ); }; const toggleHomeCategory = (cat: string) => { - setSelectedHomeCategories(prev => + setSelectedHomeCategories(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat] ); }; const toggleBlogCategory = (cat: string) => { - setSelectedBlogCategories(prev => + setSelectedBlogCategories(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat] ); }; @@ -146,19 +152,19 @@ export default function SettingsPage({ {/* Tabs Switcher */}
- -
@@ -174,26 +180,26 @@ export default function SettingsPage({
-
-
-
@@ -213,11 +219,10 @@ export default function SettingsPage({ 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' - }`} + 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} @@ -234,9 +239,9 @@ export default function SettingsPage({
-
-
-
@@ -287,12 +292,12 @@ export default function SettingsPage({
-
@@ -305,11 +310,10 @@ export default function SettingsPage({ 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' - }`} + 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' + }`} > {post.title} {selectedPostIds.includes(post.id) && SÉLECTIONNÉ} @@ -327,11 +331,10 @@ export default function SettingsPage({ 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' - }`} + 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} @@ -353,10 +356,10 @@ export default function SettingsPage({
-
@@ -364,10 +367,10 @@ export default function SettingsPage({
-
@@ -375,10 +378,10 @@ export default function SettingsPage({
-
@@ -396,11 +399,11 @@ export default function SettingsPage({
-
@@ -408,11 +411,11 @@ export default function SettingsPage({
-
@@ -420,11 +423,11 @@ export default function SettingsPage({
-
@@ -432,11 +435,11 @@ export default function SettingsPage({
-
@@ -459,17 +462,17 @@ export default function SettingsPage({

Réinitialisation de mot de passe

-
-

@@ -485,17 +488,17 @@ export default function SettingsPage({

Vérification de compte

-
-

diff --git a/admin/src/app/slides/edit/[id]/page.tsx b/admin/src/app/slides/edit/[id]/page.tsx index 5855896..777f244 100644 --- a/admin/src/app/slides/edit/[id]/page.tsx +++ b/admin/src/app/slides/edit/[id]/page.tsx @@ -2,9 +2,11 @@ import SlideForm from "@/components/SlideForm"; import { prisma } from "@/lib/prisma"; import { notFound } from "next/navigation"; -export default async function EditSlidePage({ params }: { params: { id: string } }) { +export default async function EditSlidePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const slide = await prisma.homeSlide.findUnique({ - where: { id: params.id }, + where: { id }, include: { business: true } }); diff --git a/admin/src/components/BlogForm.tsx b/admin/src/components/ActualitesForm.tsx similarity index 97% rename from admin/src/components/BlogForm.tsx rename to admin/src/components/ActualitesForm.tsx index fd63dc7..1c686c3 100644 --- a/admin/src/components/BlogForm.tsx +++ b/admin/src/components/ActualitesForm.tsx @@ -2,7 +2,7 @@ import { useTransition, useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { createBlogPost, updateBlogPost } from '@/app/actions/blog'; +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'; @@ -79,7 +79,7 @@ export default function BlogForm({ initialData }: Props) { if (result.success) { toast.success(initialData ? "Article mis à jour" : "Article créé avec succès"); - router.push('/blog'); + router.push('/actualites'); router.refresh(); } else { toast.error(result.error || "Une erreur est survenue"); @@ -91,7 +91,7 @@ export default function BlogForm({ initialData }: Props) {

- +

diff --git a/admin/src/components/ApproveContentButton.tsx b/admin/src/components/ApproveContentButton.tsx new file mode 100644 index 0000000..b94a56e --- /dev/null +++ b/admin/src/components/ApproveContentButton.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/admin/src/components/DeleteBlogButton.tsx b/admin/src/components/DeleteActualitesButton.tsx similarity index 85% rename from admin/src/components/DeleteBlogButton.tsx rename to admin/src/components/DeleteActualitesButton.tsx index 2e6685f..e5853d7 100644 --- a/admin/src/components/DeleteBlogButton.tsx +++ b/admin/src/components/DeleteActualitesButton.tsx @@ -1,11 +1,11 @@ "use client"; import { useTransition } from 'react'; -import { deleteBlogPost } from '@/app/actions/blog'; +import { deleteBlogPost } from '@/app/actions/actualites'; import { Trash2, Loader2 } from 'lucide-react'; import { toast } from 'react-hot-toast'; -export default function DeleteBlogButton({ id }: { id: string }) { +export default function DeleteActualitesButton({ id }: { id: string }) { const [isPending, startTransition] = useTransition(); const handleDelete = () => { diff --git a/admin/src/components/EventForm.tsx b/admin/src/components/EventForm.tsx index e8dd639..c443028 100644 --- a/admin/src/components/EventForm.tsx +++ b/admin/src/components/EventForm.tsx @@ -67,7 +67,7 @@ export default function EventForm({ initialData }: Props) { if (result.success) { toast.success(initialData ? "Événement mis à jour" : "Événement créé avec succès"); - router.push('/blog'); + router.push('/actualites'); router.refresh(); } else { toast.error(result.error || "Une erreur est survenue"); @@ -79,7 +79,7 @@ export default function EventForm({ initialData }: Props) {
- +

diff --git a/admin/src/components/ImageUploader.tsx b/admin/src/components/ImageUploader.tsx index 4c4dd5d..bf8b42c 100644 --- a/admin/src/components/ImageUploader.tsx +++ b/admin/src/components/ImageUploader.tsx @@ -92,7 +92,7 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam 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" @@ -143,7 +143,7 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam

{/* Hidden input for value to be sent via form if needed */} - +
)}
- +

diff --git a/admin/src/components/OneShotPricingSettings.tsx b/admin/src/components/OneShotPricingSettings.tsx new file mode 100644 index 0000000..02f9f8f --- /dev/null +++ b/admin/src/components/OneShotPricingSettings.tsx @@ -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(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 ( +
+
+
+
+ +

Tarifs des Services Ponctuels (One-Shot)

+
+ +
+
+

+ Configurez les prix des services à l'acte. Ces tarifs seront affichés sur la page de tarification. +

+ +
+ {allTypes.map((type) => { + const plan = getPlanByType(type); + const Icon = TYPE_ICONS[type]; + + return ( +
+
+
+
+ +
+
+

{TYPE_LABELS[type]}

+

{type}

+
+
+ +
{ + 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" + > +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index 24d5f09..8739206 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -26,8 +26,8 @@ const menuItems = [ { name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' }, { name: 'Commentaires', icon: MessageSquare, href: '/comments' }, { name: 'Modération', icon: Flag, href: '/moderation' }, - { name: 'Blog & Interviews', icon: BookOpen, href: '/blog' }, - { name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' }, + { 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' }, diff --git a/admin/src/components/SlideForm.tsx b/admin/src/components/SlideForm.tsx index 101e385..1d58c29 100644 --- a/admin/src/components/SlideForm.tsx +++ b/admin/src/components/SlideForm.tsx @@ -51,7 +51,7 @@ export default function SlideForm({ initialData }: Props) { businessId: type === 'BUSINESS' ? selectedBusiness?.id : null, title: formData.get('title') as string, subtitle: formData.get('subtitle') as string, - imageUrl: type === 'CUSTOM' ? imageUrl : (selectedBusiness?.logoUrl || ''), + imageUrl: type === 'CUSTOM' ? imageUrl : (selectedBusiness?.coverUrl || selectedBusiness?.logoUrl || ''), linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`, order: parseInt(formData.get('order') as string) || 0, isActive, @@ -148,8 +148,9 @@ export default function SlideForm({ initialData }: Props) {
setSearchQuery(e.target.value)} placeholder="Tapez le nom d'une entreprise..." className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500" @@ -196,8 +197,9 @@ export default function SlideForm({ initialData }: Props) {
@@ -205,8 +207,9 @@ export default function SlideForm({ initialData }: Props) {
@@ -216,8 +219,9 @@ export default function SlideForm({ initialData }: Props) {
@@ -240,9 +244,10 @@ export default function SlideForm({ initialData }: Props) {
diff --git a/admin/src/lib/prisma.ts b/admin/src/lib/prisma.ts index 009afac..cfcb4f8 100644 --- a/admin/src/lib/prisma.ts +++ b/admin/src/lib/prisma.ts @@ -1,6 +1,6 @@ import { PrismaClient } from '@prisma/client' import { PrismaPg } from '@prisma/adapter-pg' -// Updated: 2026-04-18 14:28 (Forced reload for PricingPlan model) +// Updated: 2026-05-10T17:21 (Forced reload for OneShotPlan model) import pg from 'pg' const connectionString = process.env.DATABASE_URL! @@ -11,10 +11,10 @@ function makePrisma() { return new PrismaClient({ adapter }) } -const globalForPrisma = globalThis as unknown as { prisma_v3: PrismaClient } +const globalForPrisma = globalThis as unknown as { prisma_v4: PrismaClient } -export const prisma = globalForPrisma.prisma_v3 || makePrisma() +export const prisma = globalForPrisma.prisma_v4 || makePrisma() -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v3 = prisma +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v4 = prisma export default prisma diff --git a/app/HomeClient.tsx b/app/HomeClient.tsx index 1435d54..5830bdc 100644 --- a/app/HomeClient.tsx +++ b/app/HomeClient.tsx @@ -90,8 +90,8 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialS

{settings?.homeBlogTitle || "Derniers Articles"}

{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}

- - Voir tout le blog + + Voir les actualités
@@ -108,7 +108,7 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialS }) .slice(0, settings?.homeBlogCount || 3) .map(post => ( - +
{post.title}
diff --git a/app/actions/events.ts b/app/actions/events.ts new file mode 100644 index 0000000..9d323c9 --- /dev/null +++ b/app/actions/events.ts @@ -0,0 +1,42 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; +import { ContentStatus } from "@prisma/client"; + +export async function createEvent(data: { + title: string; + description: string; + date: string; + location: string; + thumbnailUrl: string; + link?: string; + tags?: string[]; +}) { + try { + const slug = data.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + + const event = await prisma.event.create({ + data: { + title: data.title, + description: data.description, + date: new Date(data.date), + location: data.location, + thumbnailUrl: data.thumbnailUrl, + link: data.link || null, + tags: data.tags || [], + status: ContentStatus.PENDING, // Always pending for user submission + slug: `${slug}-${Math.random().toString(36).substring(2, 7)}` + } + }); + + revalidatePath("/evenements"); + return { success: true, event }; + } catch (error) { + console.error("Failed to create event:", error); + return { success: false, error: "Erreur lors de la création de l'événement" }; + } +} diff --git a/app/actions/news.ts b/app/actions/news.ts new file mode 100644 index 0000000..3ee2038 --- /dev/null +++ b/app/actions/news.ts @@ -0,0 +1,40 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; +import { ContentStatus } from "@prisma/client"; + +export async function createNews(data: { + title: string; + excerpt: string; + content: string; + author: string; + imageUrl: string; + tags?: string[]; +}) { + try { + const slug = data.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + + const post = await prisma.blogPost.create({ + data: { + title: data.title, + excerpt: data.excerpt, + content: data.content, + author: data.author, + imageUrl: data.imageUrl, + tags: data.tags || [], + status: ContentStatus.PENDING, // Always pending for user submission + slug: `${slug}-${Math.random().toString(36).substring(2, 7)}` + } + }); + + revalidatePath("/blog"); + return { success: true, post }; + } catch (error) { + console.error("Failed to create news:", error); + return { success: false, error: "Erreur lors de la création de l'actualité" }; + } +} diff --git a/app/blog/[id]/page.tsx b/app/actualites/[id]/page.tsx similarity index 97% rename from app/blog/[id]/page.tsx rename to app/actualites/[id]/page.tsx index 9f72b61..eded85c 100644 --- a/app/blog/[id]/page.tsx +++ b/app/actualites/[id]/page.tsx @@ -110,7 +110,7 @@ export default async function BlogPostPage({ params }: Props) { {/* Navigation */}
- + Retour aux articles
@@ -172,7 +172,7 @@ export default async function BlogPostPage({ params }: Props) {

Vous avez aimé cet article ?

Lire d'autres articles @@ -199,7 +199,7 @@ export default async function BlogPostPage({ params }: Props) { {relatedPosts.map((rPost) => (
diff --git a/app/blog/page.tsx b/app/actualites/page.tsx similarity index 91% rename from app/blog/page.tsx rename to app/actualites/page.tsx index d334b77..2eed1b7 100644 --- a/app/blog/page.tsx +++ b/app/actualites/page.tsx @@ -26,9 +26,9 @@ export default async function BlogPage() { return (
-

Le Blog de l'Entrepreneur

+

Nos Actualités

- Conseils, actualités et success stories de l'écosystème africain. + Conseils, dossiers et news de l'écosystème africain.

@@ -40,7 +40,7 @@ export default async function BlogPage() { ) : (
{posts.map(post => ( - +
{post.title}
diff --git a/app/api/one-shot-plans/route.ts b/app/api/one-shot-plans/route.ts new file mode 100644 index 0000000..6a35b6b --- /dev/null +++ b/app/api/one-shot-plans/route.ts @@ -0,0 +1,14 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export async function GET() { + try { + const plans = await prisma.oneShotPlan.findMany({ + orderBy: { type: 'asc' } + }); + return NextResponse.json(plans); + } catch (error) { + console.error("Failed to fetch one-shot plans:", error); + return NextResponse.json({ error: "Failed to fetch plans" }, { status: 500 }); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index fc704ac..5106433 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -12,6 +12,7 @@ import DashboardProfileClient from '../../components/dashboard/DashboardProfileC import DashboardMessages from '../../components/dashboard/DashboardMessages'; import DashboardOffers from '../../components/dashboard/DashboardOffers'; import PricingSection from '../../components/PricingSection'; +import OneShotPricingSection from '../../components/OneShotPricingSection'; import DashboardFavorites from '../../components/dashboard/DashboardFavorites'; import { useUser } from '../../components/UserProvider'; @@ -242,7 +243,7 @@ const DashboardContent = () => { ) : ( @@ -326,6 +327,7 @@ const DashboardContent = () => {
+
)}
diff --git a/app/subscription/page.tsx b/app/subscription/page.tsx index f5a0b24..aa61d79 100644 --- a/app/subscription/page.tsx +++ b/app/subscription/page.tsx @@ -3,6 +3,7 @@ import React from 'react'; import PricingSection from '../../components/PricingSection'; +import OneShotPricingSection from '../../components/OneShotPricingSection'; const SubscriptionPage = () => { return ( @@ -12,6 +13,7 @@ const SubscriptionPage = () => {

Rejoignez la plus grande communauté d'entrepreneurs.

+ {/* FAQ Section */}
diff --git a/components/Footer.tsx b/components/Footer.tsx index 65e730d..5bbedba 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -67,7 +67,7 @@ const Footer = ({ settings }: FooterProps) => {
  • Rechercher une entreprise
  • Inscrire mon entreprise
  • -
  • Actualités & Conseils
  • +
  • Actualités & Conseils
  • Tarifs
diff --git a/components/HomeSlider.tsx b/components/HomeSlider.tsx index 99d8a9f..faf2e6a 100644 --- a/components/HomeSlider.tsx +++ b/components/HomeSlider.tsx @@ -84,7 +84,7 @@ const HomeSlider = ({ slides, onSearch }: Props) => { {/* Background Image */}
{ Afro Life - - Blog + + Actualités
@@ -77,7 +77,7 @@ const Navbar = ({ settings }: NavbarProps) => { setIsOpen(false)}>Accueil setIsOpen(false)}>Annuaire setIsOpen(false)}>Afro Life - setIsOpen(false)}>Blog + setIsOpen(false)}>Actualités {user ? ( setIsOpen(false)}>Mon Espace ) : ( diff --git a/components/OneShotPricingSection.tsx b/components/OneShotPricingSection.tsx new file mode 100644 index 0000000..840bd98 --- /dev/null +++ b/components/OneShotPricingSection.tsx @@ -0,0 +1,251 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import { Zap, Calendar, Newspaper, Layout, Check, Smartphone, CreditCard, Loader, ShieldCheck, X } from 'lucide-react'; +import { useUser } from './UserProvider'; +import { useRouter } from 'next/navigation'; +import CreateEventModal from './dashboard/CreateEventModal'; +import CreateNewsModal from './dashboard/CreateNewsModal'; +import { toast } from 'react-hot-toast'; + +interface OneShotPlan { + id: string; + type: string; + name: string; + priceXOF: number; + priceEUR: number; + description: string | null; +} + +const TYPE_ICONS = { + BUMP: Zap, + POST_EVENT: Calendar, + POST_NEWS: Newspaper, + AD_DIRECTORY: Layout, +}; + +const OneShotPricingSection = () => { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedPlan, setSelectedPlan] = useState(null); + const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false); + const [paymentStep, setPaymentStep] = useState<'method' | 'processing' | 'success'>('method'); + const [paymentMethod, setPaymentMethod] = useState<'mobile_money' | 'card' | null>(null); + const [showEventModal, setShowEventModal] = useState(false); + const [showNewsModal, setShowNewsModal] = useState(false); + const { user } = useUser(); + const router = useRouter(); + + useEffect(() => { + const fetchPlans = async () => { + try { + const res = await fetch('/api/one-shot-plans'); + const data = await res.json(); + if (!data.error) { + setPlans(data); + } + } catch (error) { + console.error('Error fetching one-shot plans:', error); + } finally { + setLoading(false); + } + }; + fetchPlans(); + }, []); + + const handleSelectPlan = (plan: OneShotPlan) => { + if (!user) { + toast.error("Veuillez vous connecter pour commander un service"); + router.push('/login?callback=/subscription'); + return; + } + setSelectedPlan(plan); + setPaymentStep('method'); + setIsPaymentModalOpen(true); + }; + + const handlePayment = () => { + setPaymentStep('processing'); + setTimeout(() => { + setPaymentStep('success'); + }, 2500); + }; + + const closePayment = () => { + setIsPaymentModalOpen(false); + setPaymentStep('method'); + setSelectedPlan(null); + setPaymentMethod(null); + }; + + if (!loading && plans.length === 0) return null; + + return ( +
+
+
+

Services à la carte

+

+ Boostez votre visibilité ponctuellement +

+

+ Des options flexibles pour mettre en avant vos actualités et vos offres sans engagement. +

+
+ +
+ {loading ? ( + [1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+ )) + ) : ( + plans.map((plan) => { + const Icon = TYPE_ICONS[plan.type as keyof typeof TYPE_ICONS] || Zap; + return ( +
+
+ +
+

{plan.name}

+

{plan.description}

+ +
+ {plan.priceXOF.toLocaleString()} F CFA +

env. {plan.priceEUR} €

+
+ + +
+ ); + }) + )} +
+
+ + {/* PAYMENT MODAL (Simplified copy from PricingSection) */} + {isPaymentModalOpen && selectedPlan && ( +
+
+
+
+
+
+

Paiement Service Ponctuel

+ +
+ + {paymentStep === 'method' && ( + <> +
+

Option : {selectedPlan.name}

+

{selectedPlan.priceXOF.toLocaleString()} F CFA

+
+ +
+ + + +
+ + + + )} + + {paymentStep === 'processing' && ( +
+ +

Sécurisation du paiement...

+

N'interrompez pas la transaction.

+
+ )} + + {paymentStep === 'success' && ( +
+
+ +
+

Paiement Réussi !

+

+ {selectedPlan.type === 'POST_EVENT' || selectedPlan.type === 'POST_NEWS' + ? "Votre paiement a été validé. Vous pouvez maintenant remplir le formulaire de votre publication." + : `Votre service ${selectedPlan.name} a été activé avec succès.` + } +

+ {selectedPlan.type === 'POST_EVENT' || selectedPlan.type === 'POST_NEWS' ? ( + + ) : ( + + )} +
+ )} +
+
+
+
+ )} + + {/* EVENT FORM MODAL */} + { + setShowEventModal(false); + closePayment(); + }} + /> + {/* NEWS FORM MODAL */} + { + setShowNewsModal(false); + closePayment(); + }} + /> +
+ ); +}; + +export default OneShotPricingSection; diff --git a/components/dashboard/CreateEventModal.tsx b/components/dashboard/CreateEventModal.tsx new file mode 100644 index 0000000..8c73bb0 --- /dev/null +++ b/components/dashboard/CreateEventModal.tsx @@ -0,0 +1,229 @@ +"use client"; + +import React, { useState, useTransition } from 'react'; +import { X, Calendar, MapPin, Link as LinkIcon, Image as ImageIcon, AlertTriangle, Loader2 } from 'lucide-react'; +import { createEvent } from '@/app/actions/events'; +import { uploadImage } from '@/app/actions/upload'; +import { toast } from 'react-hot-toast'; + +interface CreateEventModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function CreateEventModal({ isOpen, onClose }: CreateEventModalProps) { + const [isPending, startTransition] = useTransition(); + const [imagePreview, setImagePreview] = useState(null); + const [uploading, setUploading] = useState(false); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + const data = { + title: formData.get('title') as string, + description: formData.get('description') as string, + date: formData.get('date') as string, + location: formData.get('location') as string, + thumbnailUrl: imagePreview || "https://picsum.photos/800/400?random=event", + link: formData.get('link') as string || undefined, + }; + + if (!data.title || !data.description || !data.date || !data.location) { + toast.error("Veuillez remplir tous les champs obligatoires"); + return; + } + + startTransition(async () => { + const result = await createEvent(data); + if (result.success) { + toast.success("Événement soumis avec succès !"); + onClose(); + } else { + toast.error(result.error || "Une erreur est survenue"); + } + }); + }; + + const handleImageChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + const formData = new FormData(); + formData.append('file', file); + + const result = await uploadImage(formData); + if (result.success && result.url) { + setImagePreview(result.url); + } else { + toast.error(result.error || "Erreur lors de l'upload"); + } + setUploading(false); + }; + + return ( +
+
+
+
+
+ +
+
+

Publier un événement

+

Partagez votre actualité avec la communauté

+
+
+ +
+ +
+ {/* Warning */} +
+
+ +
+
+

Information importante

+

+ Votre événement sera soumis à la validation de notre équipe de modération avant d'être publié sur la plateforme (généralement sous 24h). +

+
+
+ +
+
+ + +
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+ +
+ +
+ {imagePreview ? ( +
+ Preview + +
+ ) : ( + <> + {uploading ? ( + + ) : ( + + )} +

Cliquez pour ajouter une image

+

PNG, JPG jusqu'à 2Mo

+ + + )} +
+
+ +
+ +
+ + +
+
+
+ +
+ + +
+ +
+
+ ); +} diff --git a/components/dashboard/CreateNewsModal.tsx b/components/dashboard/CreateNewsModal.tsx new file mode 100644 index 0000000..d1eef9b --- /dev/null +++ b/components/dashboard/CreateNewsModal.tsx @@ -0,0 +1,216 @@ +"use client"; + +import React, { useState, useTransition } from 'react'; +import { X, Newspaper, User, Image as ImageIcon, AlertTriangle, Loader2, AlignLeft } from 'lucide-react'; +import { createNews } from '@/app/actions/news'; +import { uploadImage } from '@/app/actions/upload'; +import { toast } from 'react-hot-toast'; + +interface CreateNewsModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function CreateNewsModal({ isOpen, onClose }: CreateNewsModalProps) { + const [isPending, startTransition] = useTransition(); + const [imagePreview, setImagePreview] = useState(null); + const [uploading, setUploading] = useState(false); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + const data = { + title: formData.get('title') as string, + excerpt: formData.get('excerpt') as string, + content: formData.get('content') as string, + author: formData.get('author') as string, + imageUrl: imagePreview || "https://picsum.photos/800/400?random=news", + }; + + if (!data.title || !data.excerpt || !data.content || !data.author) { + toast.error("Veuillez remplir tous les champs obligatoires"); + return; + } + + startTransition(async () => { + const result = await createNews(data); + if (result.success) { + toast.success("Actualité soumise avec succès !"); + onClose(); + } else { + toast.error(result.error || "Une erreur est survenue"); + } + }); + }; + + const handleImageChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + const formData = new FormData(); + formData.append('file', file); + + const result = await uploadImage(formData); + if (result.success && result.url) { + setImagePreview(result.url); + } else { + toast.error(result.error || "Erreur lors de l'upload"); + } + setUploading(false); + }; + + return ( +
+
+
+
+
+ +
+
+

Publier une actualité

+

Mettez en avant vos succès et nouveautés

+
+
+ +
+ +
+ {/* Warning */} +
+
+ +
+
+

Information importante

+

+ Votre article sera soumis à la validation de notre équipe de modération avant d'être publié dans la section Actualités. +

+
+
+ +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ +
+ {imagePreview ? ( +
+ Preview + +
+ ) : ( + <> + {uploading ? ( + + ) : ( + + )} +

Cliquez pour ajouter une image

+

PNG, JPG jusqu'à 2Mo

+ + + )} +
+
+
+ +
+ + +
+ +
+
+ ); +} diff --git a/lib/prisma.ts b/lib/prisma.ts index 46f5636..757c49c 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -10,11 +10,11 @@ function makePrisma() { return new PrismaClient({ adapter }) } -const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } +const globalForPrisma = globalThis as unknown as { prisma_v2: PrismaClient } -// Sync triggered for websiteUrl and privacy fields -export const prisma = globalForPrisma.prisma || makePrisma() +// Updated: 2026-05-10T17:21 (Forced reload for OneShotPlan model) +export const prisma = globalForPrisma.prisma_v2 || makePrisma() -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v2 = prisma export default prisma diff --git a/package.json b/package.json index 6dc4a8f..51fc51c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "all": "concurrently \"npm run dev\" \"npm run dev:admin\"", "build": "prisma generate && next build && npm run build:admin", "build:admin": "npm install --prefix admin && prisma generate && npm run build --prefix admin", + "generate": "npx prisma generate && cd admin && npx prisma generate && cd ..", "start": "npx prisma migrate deploy && next start", "start:admin": "npm run start --prefix admin", "db:migrate": "prisma migrate dev", @@ -61,4 +62,4 @@ "postcss": "^8.5.10", "@hono/node-server": "^1.19.13" } -} +} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 183f8b5..abb5f64 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -317,6 +317,23 @@ model SiteSetting { verifyEmailTemplate String @default("

{siteName}

Bonjour {name},

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 :

Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.


© 2025 {siteName}. Tous droits réservés.

") } +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 @@ -361,6 +378,7 @@ enum HomeSlideType { enum ContentStatus { DRAFT + PENDING PUBLISHED ARCHIVED } diff --git a/scratch/debug-prisma.ts b/scratch/debug-prisma.ts new file mode 100644 index 0000000..240a54a --- /dev/null +++ b/scratch/debug-prisma.ts @@ -0,0 +1,7 @@ +import { prisma } from './admin/src/lib/prisma'; + +async function test() { + console.log('Prisma keys:', Object.keys(prisma)); +} + +test();

{new Date(post.createdAt).toLocaleDateString('fr-FR')}
- + - +