feat: implement comprehensive CMS dashboard for managing news, events, interviews, and one-shot pricing plans

This commit is contained in:
2026-05-10 17:50:56 +02:00
parent 87b13dce13
commit 9846efd03e
40 changed files with 1487 additions and 175 deletions

View File

@@ -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("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{verifyUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Vérifier mon compte</a></div><p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">&copy; 2025 {siteName}. Tous droits réservés.</p></div>")
}
model OneShotPlan {
id String @id @default(uuid())
type OneShotType @unique
name String
priceXOF Int @default(0)
priceEUR Int @default(0)
description String?
updatedAt DateTime @updatedAt
}
enum OneShotType {
BUMP
POST_EVENT
POST_NEWS
AD_DIRECTORY
}
model LegalDocument {
id String @id @default(uuid())
type String @unique
@@ -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
}

View File

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

View File

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

View File

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

View File

@@ -102,6 +102,8 @@ export async function searchBusinesses(query: string) {
id: true,
name: true,
logoUrl: true,
coverUrl: true,
description: true,
location: true,
}
});

View File

@@ -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 <BlogForm initialData={post} />;
return <ActualitesForm initialData={post} />;
}

View File

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

View File

@@ -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 <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-500 border border-amber-500/20 flex items-center gap-1 w-fit"><Clock className="w-3 h-3" /> EN ATTENTE</span>;
case 'DRAFT': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-500/10 text-slate-400 border border-slate-500/20">BROUILLON</span>;
case 'PUBLISHED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">PUBLIÉ</span>;
case 'ARCHIVED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20">ARCHIVÉ</span>;
case 'ARCHIVED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-red-500/10 text-red-400 border border-red-500/20 text-xs tracking-tighter">REJETÉ/ARCHIVÉ</span>;
default: return null;
}
}
export default async function 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 (
<div className="space-y-8">
<div className="flex justify-between items-end mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">CMS Blog, Interviews & Événements</h1>
<h1 className="text-3xl font-bold text-white mb-2">CMS Actualités, Interviews & Événements</h1>
<p className="text-slate-400">Gérez le contenu éditorial et événementiel de la plateforme.</p>
</div>
<div className="flex flex-wrap gap-4">
<Link href="/blog/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
<Link href="/actualites/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
<Plus className="w-4 h-4" />
Nouvel Article
</Link>
@@ -47,12 +53,77 @@ export default async function BlogCMSPage() {
</div>
</div>
{/* PENDING SUBMISSIONS SECTION */}
{hasPending && (
<div className="bg-amber-500/5 border border-amber-500/20 rounded-2xl p-6 mb-8 animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-amber-500/20 rounded-xl text-amber-500">
<ShieldAlert className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Soumissions en attente ({pendingPosts.length + pendingEvents.length})</h2>
<p className="text-sm text-slate-400">Contenu soumis par les utilisateurs nécessitant une validation.</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4">
{pendingPosts.map(post => (
<div key={post.id} className="bg-slate-900/50 border border-slate-800 rounded-xl p-4 flex items-center justify-between group hover:border-amber-500/30 transition-all">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-indigo-500/10 rounded-lg flex items-center justify-center text-indigo-400">
<BookOpen className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-white">{post.title}</h4>
<span className="text-[10px] bg-indigo-500/10 text-indigo-400 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter border border-indigo-500/20">Article</span>
</div>
<p className="text-xs text-slate-500 mt-0.5">Par {post.author} {new Date(post.createdAt).toLocaleDateString('fr-FR')}</p>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Link href={`/actualites/edit/${post.id}`} className="p-2 text-slate-400 hover:text-white" title="Modifier/Voir">
<Edit className="w-5 h-5" />
</Link>
<ApproveContentButton id={post.id} type="post" action="approve" />
<ApproveContentButton id={post.id} type="post" action="reject" />
</div>
</div>
))}
{pendingEvents.map(event => (
<div key={event.id} className="bg-slate-900/50 border border-slate-800 rounded-xl p-4 flex items-center justify-between group hover:border-amber-500/30 transition-all">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-amber-500/10 rounded-lg flex items-center justify-center text-amber-500">
<Calendar className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-white">{event.title}</h4>
<span className="text-[10px] bg-amber-500/10 text-amber-400 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter border border-amber-500/20">Événement</span>
</div>
<p className="text-xs text-slate-500 mt-0.5">{new Date(event.date).toLocaleDateString('fr-FR')} {event.location}</p>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Link href={`/event/edit/${event.id}`} className="p-2 text-slate-400 hover:text-white" title="Modifier/Voir">
<Edit className="w-5 h-5" />
</Link>
<ApproveContentButton id={event.id} type="event" action="approve" />
<ApproveContentButton id={event.id} type="event" action="reject" />
</div>
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 gap-8">
{/* Blog Posts Section */}
<div className="card">
<div className="flex items-center gap-2 mb-6">
<BookOpen className="text-indigo-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Articles de Blog ({posts.length})</h2>
<h2 className="text-xl font-bold text-white">Articles d'Actualités ({posts.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="data-table w-full">
@@ -77,10 +148,10 @@ export default async function BlogCMSPage() {
<td className="py-4 px-4 text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
<td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2">
<Link href={`/blog/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
<Link href={`/actualites/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
<Edit className="w-5 h-5" />
</Link>
<DeleteBlogButton id={post.id} />
<DeleteActualitesButton id={post.id} />
</div>
</td>
</tr>

View File

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

View File

@@ -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<any[]>([]);
const [oneShotPlans, setOneShotPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editingPlan, setEditingPlan] = useState<any>(null);
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
useEffect(() => {
async function loadPlans() {
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() {
<div className="p-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Tarifs & Forfaits</h1>
<p className="text-slate-400">Modifiez les noms, prix et limites d'offres affichés sur le site.</p>
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Forfaits & Services</h1>
<p className="text-slate-400">Modifiez les abonnements mensuels et les services ponctuels (One-Shot).</p>
</div>
{/* Toggle Billing */}
@@ -129,6 +141,11 @@ export default function PlansPage() {
currentCycle={billingCycle}
/>
)}
{/* One Shot Plans Section */}
<div className="mt-16 pt-16 border-t border-slate-800">
<OneShotPricingSettings initialPlans={oneShotPlans} />
</div>
</div>
);
}

View File

@@ -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<any>(null);
@@ -48,25 +48,31 @@ export default function SettingsPage({
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
const [resetPasswordTemplate, setResetPasswordTemplate] = useState('');
const [verifyEmailTemplate, setVerifyEmailTemplate] = useState('');
// Use a state for the tab if we want it to be fully client-side reactive without full reload
// But since the user wants it "like moderation center", I'll use URL search params
const [currentTab, setCurrentTab] = useState('general');
useEffect(() => {
async function loadData() {
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 */}
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
<button
onClick={() => setCurrentTab('general')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'general' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
<button
onClick={() => setCurrentTab('general')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'general' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Globe className="w-4 h-4" />
Général
<Globe className="w-4 h-4" />
Général
</button>
<button
onClick={() => setCurrentTab('emails')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'emails' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
<button
onClick={() => setCurrentTab('emails')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'emails' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Mail className="w-4 h-4" />
Configuration Mail
<Mail className="w-4 h-4" />
Configuration Mail
</button>
</div>
@@ -174,26 +180,26 @@ export default function SettingsPage({
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du site</label>
<input
name="siteName"
<input
name="siteName"
defaultValue={settings?.siteName}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Slogan du site</label>
<input
name="siteSlogan"
<input
name="siteSlogan"
defaultValue={settings?.siteSlogan}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-sm font-medium text-slate-400">Texte du pied de page (Footer)</label>
<input
name="footerText"
<input
name="footerText"
defaultValue={settings?.footerText}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
@@ -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}
</button>
@@ -234,9 +239,9 @@ export default function SettingsPage({
</div>
<div className="p-6 space-y-6">
<div className="flex items-center gap-3 p-3 bg-indigo-600/10 border border-indigo-500/20 rounded-xl">
<input
type="checkbox"
name="homeBlogShow"
<input
type="checkbox"
name="homeBlogShow"
id="homeBlogShow"
defaultChecked={settings?.homeBlogShow}
className="w-5 h-5 rounded border-slate-700 bg-slate-950 text-indigo-600 focus:ring-indigo-500"
@@ -247,18 +252,18 @@ export default function SettingsPage({
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre de la section</label>
<input
name="homeBlogTitle"
<input
name="homeBlogTitle"
defaultValue={settings?.homeBlogTitle}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
<input
name="homeBlogSubtitle"
<input
name="homeBlogSubtitle"
defaultValue={settings?.homeBlogSubtitle}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
@@ -287,12 +292,12 @@ export default function SettingsPage({
<div className="grid grid-cols-1 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nombre maximum d'articles</label>
<input
<input
type="number"
name="homeBlogCount"
name="homeBlogCount"
defaultValue={settings?.homeBlogCount}
min="1" max="12"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
@@ -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'
}`}
>
<span>{post.title}</span>
{selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</span>}
@@ -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}
</button>
@@ -353,10 +356,10 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Email de support</label>
<div className="relative">
<Mail className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="contactEmail"
<input
name="contactEmail"
defaultValue={settings?.contactEmail}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
@@ -364,10 +367,10 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Téléphone de contact</label>
<div className="relative">
<Phone className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="contactPhone"
<input
name="contactPhone"
defaultValue={settings?.contactPhone}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
@@ -375,10 +378,10 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Adresse physique</label>
<div className="relative">
<MapPin className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="address"
<input
name="address"
defaultValue={settings?.address}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
@@ -396,11 +399,11 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Facebook URL</label>
<div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="facebookUrl"
<input
name="facebookUrl"
defaultValue={settings?.facebookUrl}
placeholder="https://facebook.com/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
@@ -408,11 +411,11 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
<div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="twitterUrl"
<input
name="twitterUrl"
defaultValue={settings?.twitterUrl}
placeholder="https://twitter.com/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
@@ -420,11 +423,11 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">Instagram URL</label>
<div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="instagramUrl"
<input
name="instagramUrl"
defaultValue={settings?.instagramUrl}
placeholder="https://instagram.com/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
@@ -432,11 +435,11 @@ export default function SettingsPage({
<label className="text-sm font-medium text-slate-400">LinkedIn URL</label>
<div className="relative">
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
<input
name="linkedinUrl"
<input
name="linkedinUrl"
defaultValue={settings?.linkedinUrl}
placeholder="https://linkedin.com/in/..."
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
@@ -459,17 +462,17 @@ export default function SettingsPage({
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">Réinitialisation de mot de passe</h3>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
<input
name="resetPasswordSubject"
<input
name="resetPasswordSubject"
defaultValue={settings?.resetPasswordSubject}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
<RichTextEditor
value={resetPasswordTemplate}
onChange={setResetPasswordTemplate}
<RichTextEditor
value={resetPasswordTemplate}
onChange={setResetPasswordTemplate}
placeholder="Contenu de l'email..."
/>
<p className="text-[10px] text-slate-500">
@@ -485,17 +488,17 @@ export default function SettingsPage({
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider">Vérification de compte</h3>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
<input
name="verifyEmailSubject"
<input
name="verifyEmailSubject"
defaultValue={settings?.verifyEmailSubject}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
<RichTextEditor
value={verifyEmailTemplate}
onChange={setVerifyEmailTemplate}
<RichTextEditor
value={verifyEmailTemplate}
onChange={setVerifyEmailTemplate}
placeholder="Contenu de l'email..."
/>
<p className="text-[10px] text-slate-500">

View File

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

View File

@@ -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) {
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<Link href="/actualites" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">

View File

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

View File

@@ -1,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 = () => {

View File

@@ -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) {
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<Link href="/actualites" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">

View File

@@ -92,7 +92,7 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
<input
type="text"
name={name}
value={value}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "https://images.unsplash.com/..."}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500 transition-colors"
@@ -143,7 +143,7 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
</button>
</div>
{/* Hidden input for value to be sent via form if needed */}
<input type="hidden" name={name} value={value} />
<input type="hidden" name={name} value={value || ""} />
</div>
)}
<input

View File

@@ -71,7 +71,7 @@ export default function InterviewForm({ initialData }: Props) {
if (result.success) {
toast.success(initialData ? "Interview mise à jour" : "Interview créée avec succès");
router.push('/blog');
router.push('/actualites');
router.refresh();
} else {
toast.error(result.error || "Une erreur est survenue");
@@ -83,7 +83,7 @@ export default function InterviewForm({ initialData }: Props) {
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<Link href="/actualites" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">

View File

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

View File

@@ -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' },

View File

@@ -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) {
<div className="relative">
<Search className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
<input
key="search-query-input"
type="text"
value={searchQuery}
value={searchQuery || ""}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Tapez le nom d'une entreprise..."
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
@@ -196,8 +197,9 @@ export default function SlideForm({ initialData }: Props) {
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre</label>
<input
key="custom-title-input"
name="title"
defaultValue={initialData?.title}
defaultValue={initialData?.title || ""}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
placeholder="Ex: Découvrez l'artisanat local"
/>
@@ -205,8 +207,9 @@ export default function SlideForm({ initialData }: Props) {
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
<input
key="custom-subtitle-input"
name="subtitle"
defaultValue={initialData?.subtitle}
defaultValue={initialData?.subtitle || ""}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
placeholder="Ex: Des produits uniques faits main"
/>
@@ -216,8 +219,9 @@ export default function SlideForm({ initialData }: Props) {
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Lien (URL)</label>
<input
key="custom-link-input"
name="linkUrl"
defaultValue={initialData?.linkUrl}
defaultValue={initialData?.linkUrl || ""}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
placeholder="Ex: /annuaire ou https://..."
/>
@@ -240,9 +244,10 @@ export default function SlideForm({ initialData }: Props) {
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Ordre d'affichage</label>
<input
key="order-input"
name="order"
type="number"
defaultValue={initialData?.order || 0}
defaultValue={initialData?.order ?? 0}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="0"
/>

View File

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