Compare commits
20 Commits
371ed17867
...
7db35361d9
| Author | SHA1 | Date | |
|---|---|---|---|
| 7db35361d9 | |||
| b6f828357d | |||
| 9846efd03e | |||
| 87b13dce13 | |||
| 13b1730f6f | |||
| 5176e51e21 | |||
| f4f303b703 | |||
| 7174736680 | |||
| 889d3967d2 | |||
| 94de36e937 | |||
| 15700036e5 | |||
| fe7acfa927 | |||
| c6c6a05c47 | |||
| f64b8ba53c | |||
| 3567e5d224 | |||
| 863e8fdc4a | |||
| cd862e503d | |||
| 343f29459b | |||
|
|
4db8271ecf | ||
|
|
e6d6770c26 |
4
admin/package-lock.json
generated
4
admin/package-lock.json
generated
@@ -47,13 +47,12 @@
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"isomorphic-dompurify": "^3.11.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"next": "^16.1.6",
|
||||
@@ -65,6 +64,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"recharts": "^3.8.1",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2"
|
||||
|
||||
@@ -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;\">© 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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
64
admin/src/app/actions/approval.ts
Normal file
64
admin/src/app/actions/approval.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
40
admin/src/app/actions/one-shot.ts
Normal file
40
admin/src/app/actions/one-shot.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
114
admin/src/app/actions/slides.ts
Normal file
114
admin/src/app/actions/slides.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function getSlides() {
|
||||
try {
|
||||
return await prisma.homeSlide.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
include: { business: true }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get slides:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSlide(data: any) {
|
||||
try {
|
||||
await prisma.homeSlide.create({
|
||||
data: {
|
||||
type: data.type,
|
||||
businessId: data.businessId || null,
|
||||
title: data.title || null,
|
||||
subtitle: data.subtitle || null,
|
||||
imageUrl: data.imageUrl || null,
|
||||
linkUrl: data.linkUrl || null,
|
||||
order: data.order || 0,
|
||||
isActive: data.isActive ?? true,
|
||||
}
|
||||
});
|
||||
revalidatePath("/slides");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to create slide:", error);
|
||||
return { success: false, error: "Erreur lors de la création" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSlide(id: string, data: any) {
|
||||
try {
|
||||
await prisma.homeSlide.update({
|
||||
where: { id },
|
||||
data: {
|
||||
type: data.type,
|
||||
businessId: data.businessId || null,
|
||||
title: data.title || null,
|
||||
subtitle: data.subtitle || null,
|
||||
imageUrl: data.imageUrl || null,
|
||||
linkUrl: data.linkUrl || null,
|
||||
order: data.order || 0,
|
||||
isActive: data.isActive ?? true,
|
||||
}
|
||||
});
|
||||
revalidatePath("/slides");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to update slide:", error);
|
||||
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSlide(id: string) {
|
||||
try {
|
||||
await prisma.homeSlide.delete({
|
||||
where: { id }
|
||||
});
|
||||
revalidatePath("/slides");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to delete slide:", error);
|
||||
return { success: false, error: "Erreur lors de la suppression" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleSlideStatus(id: string, currentStatus: boolean) {
|
||||
try {
|
||||
await prisma.homeSlide.update({
|
||||
where: { id },
|
||||
data: { isActive: !currentStatus }
|
||||
});
|
||||
revalidatePath("/slides");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle slide status:", error);
|
||||
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchBusinesses(query: string) {
|
||||
try {
|
||||
return await prisma.business.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: query, mode: 'insensitive' } },
|
||||
{ location: { contains: query, mode: 'insensitive' } },
|
||||
],
|
||||
isActive: true,
|
||||
},
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
coverUrl: true,
|
||||
description: true,
|
||||
location: true,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to search businesses:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
5
admin/src/app/actualites/new/page.tsx
Normal file
5
admin/src/app/actualites/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ActualitesForm from "@/components/ActualitesForm";
|
||||
|
||||
export default function NewActualitesPage() {
|
||||
return <ActualitesForm />;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,5 +0,0 @@
|
||||
import BlogForm from "@/components/BlogForm";
|
||||
|
||||
export default function NewBlogPage() {
|
||||
return <BlogForm />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
18
admin/src/app/slides/edit/[id]/page.tsx
Normal file
18
admin/src/app/slides/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import SlideForm from "@/components/SlideForm";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export default async function EditSlidePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
const slide = await prisma.homeSlide.findUnique({
|
||||
where: { id },
|
||||
include: { business: true }
|
||||
});
|
||||
|
||||
if (!slide) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <SlideForm initialData={slide} />;
|
||||
}
|
||||
5
admin/src/app/slides/new/page.tsx
Normal file
5
admin/src/app/slides/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SlideForm from "@/components/SlideForm";
|
||||
|
||||
export default function NewSlidePage() {
|
||||
return <SlideForm />;
|
||||
}
|
||||
115
admin/src/app/slides/page.tsx
Normal file
115
admin/src/app/slides/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Link from 'next/link';
|
||||
import { Plus, Image as ImageIcon, Trash2, Edit, ExternalLink, Power, PowerOff } from 'lucide-react';
|
||||
import DeleteSlideButton from '@/components/DeleteSlideButton';
|
||||
import ToggleSlideStatusButton from '@/components/ToggleSlideStatusButton';
|
||||
|
||||
async function getData() {
|
||||
try {
|
||||
return await prisma.homeSlide.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
include: { business: true }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch slides:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function SlidesPage() {
|
||||
const slides = await getData();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion de la Bannière</h1>
|
||||
<p className="text-slate-400">Gérez les slides qui s'affichent sur la page d'accueil (Slider).</p>
|
||||
</div>
|
||||
<Link href="/slides/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
|
||||
<Plus className="w-4 h-4" />
|
||||
Nouvelle Slide
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<ImageIcon className="text-indigo-400 w-5 h-5" />
|
||||
<h2 className="text-xl font-bold text-white">Slides Actuelles ({slides.length})</h2>
|
||||
</div>
|
||||
|
||||
{slides.length === 0 ? (
|
||||
<div className="text-center py-12 bg-slate-800/20 rounded-xl border border-dashed border-slate-700">
|
||||
<p className="text-slate-500">Aucune slide configurée. Le bandeau par défaut sera affiché.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4">Ordre</th>
|
||||
<th className="text-left py-3 px-4">Type</th>
|
||||
<th className="text-left py-3 px-4">Contenu</th>
|
||||
<th className="text-left py-3 px-4">Statut</th>
|
||||
<th className="text-right py-3 px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{slides.map((slide) => (
|
||||
<tr key={slide.id} className="hover:bg-slate-800/30 transition-colors">
|
||||
<td className="py-4 px-4">
|
||||
<span className="text-white font-mono bg-slate-800 px-2 py-1 rounded">#{slide.order}</span>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${slide.type === 'BUSINESS' ? 'bg-indigo-500/10 text-indigo-400 border border-indigo-500/20' : 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'}`}>
|
||||
{slide.type === 'BUSINESS' ? 'ENTREPRISE' : 'PERSONNALISÉ'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{slide.type === 'BUSINESS' ? (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
|
||||
<img src={slide.business?.logoUrl || '/placeholder-biz.png'} className="w-full h-full object-cover" alt="" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">{slide.business?.name}</div>
|
||||
<div className="text-xs text-slate-500">{slide.business?.location}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
|
||||
<img src={slide.imageUrl || '/placeholder-img.png'} className="w-full h-full object-cover" alt="" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">{slide.title || 'Sans titre'}</div>
|
||||
<div className="text-xs text-slate-500 truncate max-w-[250px]">{slide.linkUrl || 'Pas de lien'}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<ToggleSlideStatusButton id={slide.id} currentStatus={slide.isActive} />
|
||||
</td>
|
||||
<td className="py-4 px-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={`/slides/edit/${slide.id}`} className="p-2 text-slate-400 hover:text-indigo-400 transition-colors">
|
||||
<Edit className="w-5 h-5" />
|
||||
</Link>
|
||||
<DeleteSlideButton id={slide.id} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
61
admin/src/components/ApproveContentButton.tsx
Normal file
61
admin/src/components/ApproveContentButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
34
admin/src/components/DeleteSlideButton.tsx
Normal file
34
admin/src/components/DeleteSlideButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { deleteSlide } from '@/app/actions/slides';
|
||||
import { Trash2, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function DeleteSlideButton({ id }: { id: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm("Supprimer cette slide du slider d'accueil ?")) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteSlide(id);
|
||||
if (result.success) {
|
||||
toast.success("Slide supprimée");
|
||||
} else {
|
||||
toast.error(result.error || "Erreur lors de la suppression");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="p-2 text-slate-500 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
title="Supprimer la slide"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
183
admin/src/components/OneShotPricingSettings.tsx
Normal file
183
admin/src/components/OneShotPricingSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
Globe,
|
||||
FileText,
|
||||
Briefcase,
|
||||
Tags
|
||||
Tags,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
const menuItems = [
|
||||
@@ -25,10 +26,11 @@ 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' },
|
||||
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
||||
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
||||
];
|
||||
|
||||
292
admin/src/components/SlideForm.tsx
Normal file
292
admin/src/components/SlideForm.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition, useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createSlide, updateSlide, searchBusinesses } from '@/app/actions/slides';
|
||||
import { Loader2, ArrowLeft, Save, Search, Check, X, ImageIcon, Briefcase } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import ImageUploader from './ImageUploader';
|
||||
|
||||
interface Props {
|
||||
initialData?: any;
|
||||
}
|
||||
|
||||
export default function SlideForm({ initialData }: Props) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Form state
|
||||
const [type, setType] = useState(initialData?.type || 'CUSTOM');
|
||||
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
||||
const [isActive, setIsActive] = useState(initialData?.isActive ?? true);
|
||||
|
||||
// Business search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [selectedBusiness, setSelectedBusiness] = useState<any>(initialData?.business || null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.length > 1) {
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
setIsSearching(true);
|
||||
searchBusinesses(searchQuery).then(results => {
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
});
|
||||
}, 300);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const data = {
|
||||
type,
|
||||
businessId: type === 'BUSINESS' ? selectedBusiness?.id : null,
|
||||
title: formData.get('title') as string,
|
||||
subtitle: formData.get('subtitle') as string,
|
||||
imageUrl: type === 'CUSTOM' ? imageUrl : (selectedBusiness?.coverUrl || selectedBusiness?.logoUrl || ''),
|
||||
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
|
||||
order: parseInt(formData.get('order') as string) || 0,
|
||||
isActive,
|
||||
};
|
||||
|
||||
if (type === 'BUSINESS' && !data.businessId) {
|
||||
toast.error("Veuillez sélectionner une entreprise");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'CUSTOM' && !data.imageUrl) {
|
||||
toast.error("Une image est requise pour une slide personnalisée");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = initialData
|
||||
? await updateSlide(initialData.id, data)
|
||||
: await createSlide(data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(initialData ? "Slide mise à jour" : "Slide créée avec succès");
|
||||
router.push('/slides');
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || "Une erreur est survenue");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto pb-20">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/slides" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{initialData ? 'Modifier la Slide' : 'Nouvelle Slide'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div className="card space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||
<h2 className="text-xl font-semibold text-white">Type de Slide</h2>
|
||||
<div className="flex bg-slate-900 p-1 rounded-lg border border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('BUSINESS')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all ${
|
||||
type === 'BUSINESS' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Briefcase className="w-4 h-4" />
|
||||
Entreprise
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('CUSTOM')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all ${
|
||||
type === 'CUSTOM' ? 'bg-emerald-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
Personnalisé
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === 'BUSINESS' ? (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<label className="text-sm font-medium text-slate-400">Rechercher une entreprise</label>
|
||||
|
||||
{selectedBusiness ? (
|
||||
<div className="flex items-center justify-between p-4 bg-indigo-500/10 border border-indigo-500/20 rounded-xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={selectedBusiness.logoUrl} className="w-12 h-12 rounded-lg object-cover" alt="" />
|
||||
<div>
|
||||
<div className="text-white font-bold">{selectedBusiness.name}</div>
|
||||
<div className="text-xs text-indigo-400">{selectedBusiness.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedBusiness(null)}
|
||||
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
key="search-query-input"
|
||||
type="text"
|
||||
value={searchQuery || ""}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Tapez le nom d'une entreprise..."
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
|
||||
{isSearching && (
|
||||
<div className="absolute right-3 top-3">
|
||||
<Loader2 className="w-5 h-5 text-indigo-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-2 bg-slate-800 border border-slate-700 rounded-xl shadow-2xl overflow-hidden max-h-60 overflow-y-auto">
|
||||
{searchResults.map((biz) => (
|
||||
<button
|
||||
key={biz.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedBusiness(biz);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-slate-700 text-left transition-colors border-b border-slate-700 last:border-0"
|
||||
>
|
||||
<img src={biz.logoUrl} className="w-10 h-10 rounded object-cover" alt="" />
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">{biz.name}</div>
|
||||
<div className="text-xs text-slate-400">{biz.location}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-slate-900/50 rounded-lg border border-slate-800 italic text-xs text-slate-500">
|
||||
L'image, le titre et le lien seront automatiquement récupérés depuis la fiche de l'entreprise.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Titre</label>
|
||||
<input
|
||||
key="custom-title-input"
|
||||
name="title"
|
||||
defaultValue={initialData?.title || ""}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
||||
placeholder="Ex: Découvrez l'artisanat local"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
|
||||
<input
|
||||
key="custom-subtitle-input"
|
||||
name="subtitle"
|
||||
defaultValue={initialData?.subtitle || ""}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
||||
placeholder="Ex: Des produits uniques faits main"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Lien (URL)</label>
|
||||
<input
|
||||
key="custom-link-input"
|
||||
name="linkUrl"
|
||||
defaultValue={initialData?.linkUrl || ""}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
||||
placeholder="Ex: /annuaire ou https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ImageUploader
|
||||
label="Image de fond"
|
||||
value={imageUrl}
|
||||
onChange={setImageUrl}
|
||||
name="imageUrl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card space-y-6">
|
||||
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Paramètres d'Affichage</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Ordre d'affichage</label>
|
||||
<input
|
||||
key="order-input"
|
||||
name="order"
|
||||
type="number"
|
||||
defaultValue={initialData?.order ?? 0}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="0"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Les slides sont triées par ordre croissant.</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-slate-900 rounded-xl border border-slate-800">
|
||||
<span className="text-sm font-medium text-slate-300">Statut de la slide</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
|
||||
isActive ? 'bg-emerald-600' : 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
isActive ? 'translate-x-6' : 'translate-x-1'
|
||||
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-6 h-6" />
|
||||
)}
|
||||
{initialData ? 'Enregistrer les modifications' : 'Créer la slide'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
admin/src/components/ToggleSlideStatusButton.tsx
Normal file
42
admin/src/components/ToggleSlideStatusButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { toggleSlideStatus } from '@/app/actions/slides';
|
||||
import { Power, PowerOff, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function ToggleSlideStatusButton({ id, currentStatus }: { id: string, currentStatus: boolean }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleToggle = () => {
|
||||
startTransition(async () => {
|
||||
const result = await toggleSlideStatus(id, currentStatus);
|
||||
if (result.success) {
|
||||
toast.success(currentStatus ? "Slide désactivée" : "Slide activée");
|
||||
} else {
|
||||
toast.error(result.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={isPending}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
currentStatus
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/20'
|
||||
: 'bg-slate-500/10 text-slate-400 border border-slate-500/20 hover:bg-slate-500/20'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : currentStatus ? (
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<PowerOff className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{currentStatus ? 'ACTIVE' : 'INACTIVE'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -8,14 +8,16 @@ import { generateSlug } from '@/lib/utils';
|
||||
import { Business, BlogPost } from '@/types';
|
||||
import BusinessCard from '@/components/BusinessCard';
|
||||
import { useUser } from '@/components/UserProvider';
|
||||
import HomeSlider from '@/components/HomeSlider';
|
||||
|
||||
interface Props {
|
||||
initialFeatured: Business[];
|
||||
initialPosts: BlogPost[];
|
||||
initialCategories: any[];
|
||||
initialSlides: any[];
|
||||
}
|
||||
|
||||
const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props) => {
|
||||
const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialSlides }: Props) => {
|
||||
const router = useRouter();
|
||||
const { user, settings } = useUser();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -23,11 +25,11 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
||||
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
|
||||
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
||||
const [categories, setCategories] = useState<any[]>(initialCategories);
|
||||
const [slides, setSlides] = useState<any[]>(initialSlides);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingPosts, setLoadingPosts] = useState(false);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSearch = (searchTerm: string, locationTerm: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (searchTerm) params.append('q', searchTerm);
|
||||
if (locationTerm) params.append('location', locationTerm);
|
||||
@@ -36,47 +38,8 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero Section */}
|
||||
<div className="relative bg-dark-900 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-40">
|
||||
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" width={1740} height={1160} />
|
||||
</div>
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
|
||||
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
|
||||
Boostez votre visibilité dans <br />
|
||||
<span className="text-brand-500">l'écosystème africain</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
|
||||
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSearch} className="max-w-3xl bg-white p-2 rounded-lg shadow-xl flex flex-col md:flex-row gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Que recherchez-vous ? (ex: Développeur, Traiteur...)"
|
||||
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
|
||||
<MapPin className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Localisation (ex: Abidjan)"
|
||||
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
|
||||
value={locationTerm}
|
||||
onChange={(e) => setLocationTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
|
||||
Rechercher
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/* Dynamic Hero Slider */}
|
||||
<HomeSlider slides={slides} onSearch={handleSearch} />
|
||||
|
||||
{/* Featured Categories */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
@@ -127,8 +90,8 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
||||
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
|
||||
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
|
||||
</div>
|
||||
<Link href="/blog" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
|
||||
Voir tout le blog <ArrowRight className="w-4 h-4 ml-1" />
|
||||
<Link href="/actualites" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
|
||||
Voir les actualités <ArrowRight className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +108,7 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
||||
})
|
||||
.slice(0, settings?.homeBlogCount || 3)
|
||||
.map(post => (
|
||||
<Link key={post.id} href={`/blog/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
|
||||
<Link key={post.id} href={`/actualites/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
|
||||
<div className="h-48 overflow-hidden">
|
||||
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
|
||||
</div>
|
||||
|
||||
42
app/actions/events.ts
Normal file
42
app/actions/events.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ContentStatus } from "@prisma/client";
|
||||
|
||||
export async function createEvent(data: {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
location: string;
|
||||
thumbnailUrl: string;
|
||||
link?: string;
|
||||
tags?: string[];
|
||||
}) {
|
||||
try {
|
||||
const slug = data.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
date: new Date(data.date),
|
||||
location: data.location,
|
||||
thumbnailUrl: data.thumbnailUrl,
|
||||
link: data.link || null,
|
||||
tags: data.tags || [],
|
||||
status: 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" };
|
||||
}
|
||||
}
|
||||
40
app/actions/news.ts
Normal file
40
app/actions/news.ts
Normal file
@@ -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é" };
|
||||
}
|
||||
}
|
||||
236
app/actualites/[id]/page.tsx
Normal file
236
app/actualites/[id]/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, ArrowRight, User, Calendar, Share2 } from 'lucide-react';
|
||||
import { sanitizeHTML } from '../../../lib/sanitize';
|
||||
import { prisma } from '../../../lib/prisma';
|
||||
import BlogShareButtons from '../../../components/BlogShareButtons';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// Style spécifique pour empêcher strictement la coupure des mots tout en justifiant le texte
|
||||
const noBreakStyle = `
|
||||
#blog-content,
|
||||
#blog-content p,
|
||||
#blog-content li,
|
||||
#blog-content span,
|
||||
#blog-content strong {
|
||||
word-break: normal !important;
|
||||
overflow-wrap: break-word !important;
|
||||
hyphens: none !important;
|
||||
line-break: normal !important;
|
||||
text-align: justify !important;
|
||||
text-justify: inter-word !important;
|
||||
}
|
||||
|
||||
/* Sécurité supplémentaire pour les navigateurs mobiles */
|
||||
.prose * {
|
||||
word-break: normal !important;
|
||||
hyphens: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const post = await prisma.blogPost.findFirst({
|
||||
where: {
|
||||
OR: [{ id: id }, { slug: id }],
|
||||
publishedAt: { lte: new Date() },
|
||||
status: 'PUBLISHED'
|
||||
}
|
||||
});
|
||||
|
||||
if (!post) return { title: 'Article non trouvé' };
|
||||
|
||||
return {
|
||||
title: post.metaTitle || post.title,
|
||||
description: post.metaDescription || post.excerpt,
|
||||
keywords: post.tags,
|
||||
openGraph: {
|
||||
title: post.metaTitle || post.title,
|
||||
description: post.metaDescription || post.excerpt,
|
||||
images: [post.imageUrl],
|
||||
type: 'article',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const post = await prisma.blogPost.findFirst({
|
||||
where: {
|
||||
OR: [{ id: id }, { slug: id }],
|
||||
publishedAt: { lte: new Date() },
|
||||
status: 'PUBLISHED'
|
||||
}
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Articles en rapport : par tags communs, sinon les plus récents
|
||||
let relatedPosts = await prisma.blogPost.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
publishedAt: { lte: new Date() },
|
||||
id: { not: post.id },
|
||||
tags: { hasSome: post.tags }
|
||||
},
|
||||
take: 3,
|
||||
orderBy: { date: 'desc' }
|
||||
});
|
||||
|
||||
// Compléter avec les articles récents si nécessaire
|
||||
if (relatedPosts.length < 2) {
|
||||
const excludedIds = [post.id, ...relatedPosts.map(p => p.id)];
|
||||
const additionalPosts = await prisma.blogPost.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
publishedAt: { lte: new Date() },
|
||||
id: { notIn: excludedIds }
|
||||
},
|
||||
take: 3 - relatedPosts.length,
|
||||
orderBy: { date: 'desc' }
|
||||
});
|
||||
relatedPosts = [...relatedPosts, ...additionalPosts];
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="bg-gray-50 min-h-screen py-12 relative">
|
||||
{/* Injection du style de force pour le texte */}
|
||||
<style dangerouslySetInnerHTML={{ __html: noBreakStyle }} />
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 flex gap-4 lg:gap-8 justify-center relative">
|
||||
{/* Conteneur principal de l'article */}
|
||||
<div className="w-[calc(100%-3rem)] sm:w-full max-w-3xl relative">
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="mb-8">
|
||||
<Link href="/actualites" className="inline-flex items-center text-gray-500 hover:text-brand-600 text-sm font-medium transition-colors">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 md:p-10 border border-gray-100 relative z-10">
|
||||
{/* Image d'en-tête */}
|
||||
<div className="w-full h-64 md:h-80 relative mb-8 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header de l'article */}
|
||||
<header className="mb-8 border-b border-gray-100 pb-8">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
|
||||
<span className="flex items-center">
|
||||
<User className="w-4 h-4 mr-1 text-brand-500" />
|
||||
{post.author}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
|
||||
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
{post.tags.length > 0 ? (
|
||||
post.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||
Article
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Contenu de l'article */}
|
||||
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
|
||||
{/* Résumé / Lead */}
|
||||
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
{/* Corps du texte avec ID pour le contrôle CSS */}
|
||||
<div
|
||||
id="blog-content"
|
||||
className="mt-6"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHTML(post.content) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Partage / Footer */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-100 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p className="text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
||||
<Link
|
||||
href="/actualites"
|
||||
className="inline-flex items-center text-brand-600 hover:text-brand-700 font-semibold transition-colors"
|
||||
>
|
||||
Lire d'autres articles
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar flottante verticale à droite (suit l'article et s'arrête à la fin) */}
|
||||
<div className="w-10 shrink-0 relative">
|
||||
<div className="sticky top-32 z-50">
|
||||
<BlogShareButtons title={post.title} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Section Articles en rapport */}
|
||||
{relatedPosts.length > 0 && (
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 mt-16 pt-16 border-t border-gray-200">
|
||||
<h2 className="text-2xl md:text-3xl font-serif font-bold text-gray-900 mb-8">Articles en rapport</h2>
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{relatedPosts.map((rPost) => (
|
||||
<Link
|
||||
key={rPost.id}
|
||||
href={`/actualites/${rPost.slug || rPost.id}`}
|
||||
className="group bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="aspect-video relative overflow-hidden">
|
||||
<img
|
||||
src={rPost.imageUrl}
|
||||
alt={rPost.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/5 group-hover:bg-transparent transition-colors" />
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-600 bg-brand-50 px-2 py-0.5 rounded">
|
||||
{rPost.tags?.[0] || 'Article'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(rPost.date).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 group-hover:text-brand-600 transition-colors line-clamp-2 leading-snug">
|
||||
{rPost.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 line-clamp-2">
|
||||
{rPost.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -26,9 +26,9 @@ export default async function BlogPage() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold font-serif text-gray-900 sm:text-4xl">Le Blog de l'Entrepreneur</h1>
|
||||
<h1 className="text-3xl font-bold font-serif text-gray-900 sm:text-4xl">Nos Actualités</h1>
|
||||
<p className="mt-3 max-w-2xl mx-auto text-xl text-gray-500 sm:mt-4">
|
||||
Conseils, actualités et success stories de l'écosystème africain.
|
||||
Conseils, dossiers et news de l'écosystème africain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default async function BlogPage() {
|
||||
) : (
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{posts.map(post => (
|
||||
<Link key={post.id} href={`/blog/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
|
||||
<Link key={post.id} href={`/actualites/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
|
||||
<div className="flex-shrink-0 relative overflow-hidden h-48">
|
||||
<img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} />
|
||||
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
|
||||
14
app/api/one-shot-plans/route.ts
Normal file
14
app/api/one-shot-plans/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, User, Calendar, Share2 } from 'lucide-react';
|
||||
import { sanitizeHTML } from '../../../lib/sanitize';
|
||||
import { prisma } from '../../../lib/prisma';
|
||||
|
||||
import { Metadata } from 'next';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
|
||||
const post = await prisma.blogPost.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: id },
|
||||
{ slug: id }
|
||||
],
|
||||
publishedAt: {
|
||||
lte: new Date()
|
||||
},
|
||||
status: 'PUBLISHED'
|
||||
}
|
||||
});
|
||||
|
||||
if (!post) return { title: 'Article non trouvé' };
|
||||
|
||||
return {
|
||||
title: post.metaTitle || post.title,
|
||||
description: post.metaDescription || post.excerpt,
|
||||
keywords: post.tags,
|
||||
openGraph: {
|
||||
title: post.metaTitle || post.title,
|
||||
description: post.metaDescription || post.excerpt,
|
||||
images: [post.imageUrl],
|
||||
type: 'article',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
|
||||
const post = await prisma.blogPost.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: id },
|
||||
{ slug: id }
|
||||
],
|
||||
publishedAt: {
|
||||
lte: new Date()
|
||||
},
|
||||
status: 'PUBLISHED'
|
||||
}
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="bg-white min-h-screen">
|
||||
{/* Hero Image */}
|
||||
<div className="w-full h-64 md:h-96 relative">
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
<div className="absolute bottom-0 left-0 w-full p-4 sm:p-8 max-w-4xl mx-auto">
|
||||
<Link href="/blog" className="inline-flex items-center text-white/80 hover:text-white mb-4 text-sm font-medium transition-colors">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12 -mt-20 relative z-10">
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 md:p-10 border border-gray-100">
|
||||
{/* Header */}
|
||||
<header className="mb-8 border-b border-gray-100 pb-8">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
|
||||
<span className="flex items-center">
|
||||
<User className="w-4 h-4 mr-1 text-brand-500" />
|
||||
{post.author}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
|
||||
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
{post.tags.length > 0 ? (
|
||||
post.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||
Article
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
|
||||
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<div
|
||||
className="mt-6"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHTML(post.content) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer / Share */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-100 flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
||||
<div className="flex space-x-2">
|
||||
<button className="p-2 rounded-full bg-gray-100 text-gray-600 hover:bg-brand-100 hover:text-brand-600 transition-colors">
|
||||
<Share2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
</button>
|
||||
<button onClick={() => setCurrentView('subscription')} className={`${currentView === 'subscription' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
|
||||
<CreditCard className={`${currentView === 'subscription' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
|
||||
Abonnement
|
||||
Abonnement & Services
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -326,6 +327,7 @@ const DashboardContent = () => {
|
||||
</span>
|
||||
</div>
|
||||
<PricingSection />
|
||||
<OneShotPricingSection />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,16 +23,38 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-family: var(--font-sans);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose {
|
||||
text-align: justify;
|
||||
hyphens: none;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
/* FORCE ABSOLUE SUR LE CONTENU DU BLOG
|
||||
L'utilisation de l'ID #blog-content garantit que ces règles
|
||||
écrasent les styles par défaut du plugin typography.
|
||||
*/
|
||||
#blog-content,
|
||||
#blog-content *,
|
||||
.prose p,
|
||||
.prose li {
|
||||
word-break: keep-all !important;
|
||||
/* Interdit de couper les mots */
|
||||
overflow-wrap: break-word !important;
|
||||
/* Autorise le retour à la ligne si le mot est trop long */
|
||||
hyphens: none !important;
|
||||
/* Supprime les tirets de césure */
|
||||
line-break: normal !important;
|
||||
/* Empêche les cassures de caractères bizarres */
|
||||
}
|
||||
|
||||
/* Nettoyage des titres */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
word-break: normal !important;
|
||||
overflow-wrap: break-word !important;
|
||||
hyphens: none !important;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 1.25em;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
|
||||
export default async function HomePage() {
|
||||
// Fetch initial data for faster loading and SEO
|
||||
const [featuredBusinesses, posts, categories] = await Promise.all([
|
||||
const [featuredBusinesses, posts, categories, slides] = await Promise.all([
|
||||
prisma.business.findMany({
|
||||
where: { isFeatured: true, isActive: true, isSuspended: false },
|
||||
take: 4,
|
||||
@@ -58,6 +58,11 @@ export default async function HomePage() {
|
||||
prisma.businessCategory.findMany({
|
||||
where: { isActive: true },
|
||||
take: 8
|
||||
}),
|
||||
prisma.homeSlide.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { order: 'asc' },
|
||||
include: { business: true }
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -65,12 +70,14 @@ export default async function HomePage() {
|
||||
const initialFeatured = JSON.parse(JSON.stringify(featuredBusinesses));
|
||||
const initialPosts = JSON.parse(JSON.stringify(posts));
|
||||
const initialCategories = JSON.parse(JSON.stringify(categories));
|
||||
const initialSlides = JSON.parse(JSON.stringify(slides));
|
||||
|
||||
return (
|
||||
<HomeClient
|
||||
initialFeatured={initialFeatured}
|
||||
initialPosts={initialPosts}
|
||||
initialCategories={initialCategories}
|
||||
initialSlides={initialSlides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
<p className="text-gray-400 mt-2">Rejoignez la plus grande communauté d'entrepreneurs.</p>
|
||||
</div>
|
||||
<PricingSection />
|
||||
<OneShotPricingSection />
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-16">
|
||||
|
||||
31
check_db.cjs
Normal file
31
check_db.cjs
Normal file
@@ -0,0 +1,31 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const post = await prisma.blogPost.findUnique({
|
||||
where: { slug: 'cyber-dome-cato' }
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
console.log("Post not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const snippet = post.content.substring(0, 1000);
|
||||
console.log("Content start:");
|
||||
console.log(snippet);
|
||||
|
||||
const match = post.content.match(/s.e sont/);
|
||||
if (match) {
|
||||
console.log("Found 's.e sont':", match[0]);
|
||||
console.log("Hex:", Buffer.from(match[0]).toString('hex'));
|
||||
}
|
||||
|
||||
const match2 = post.content.match(/cyberattaqu.es/);
|
||||
if (match2) {
|
||||
console.log("Found 'cyberattaqu.es':", match2[0]);
|
||||
console.log("Hex:", Buffer.from(match2[0]).toString('hex'));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
73
components/BlogShareButtons.tsx
Normal file
73
components/BlogShareButtons.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Facebook, Instagram, Twitter, Link as LinkIcon, Check } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function BlogShareButtons({ title }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setUrl(window.location.href);
|
||||
}, []);
|
||||
|
||||
const handleCopy = (customMessage?: string) => {
|
||||
if (!url) return;
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
toast.success(customMessage || 'Lien copié dans le presse-papiers !');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const shareLinks = {
|
||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||
twitter: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`,
|
||||
};
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 z-50">
|
||||
<a
|
||||
href={shareLinks.facebook}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-10 h-10 rounded-full bg-white text-[#1877F2] shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
|
||||
title="Partager sur Facebook"
|
||||
>
|
||||
<Facebook className="w-5 h-5" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={shareLinks.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-10 h-10 rounded-full bg-white text-black shadow-xl flex items-center justify-center hover:scale-110 transition-transform border border-gray-100"
|
||||
title="Partager sur X (Twitter)"
|
||||
>
|
||||
<Twitter className="w-5 h-5" />
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={() => handleCopy('Lien copié ! Vous pouvez le coller sur Instagram.')}
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-tr from-[#f9ce34] via-[#ee2a7b] to-[#6228d7] text-white shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
|
||||
title="Copier le lien pour Instagram"
|
||||
>
|
||||
<Instagram className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleCopy()}
|
||||
className="w-10 h-10 rounded-full bg-gray-800 text-white shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
|
||||
title="Copier le lien"
|
||||
>
|
||||
{copied ? <Check className="w-5 h-5 text-green-400" /> : <LinkIcon className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,8 @@ const Footer = ({ settings }: FooterProps) => {
|
||||
<ul className="space-y-2 text-gray-400 text-sm">
|
||||
<li><Link href="/annuaire" className="hover:text-brand-500">Rechercher une entreprise</Link></li>
|
||||
<li><Link href="/login" className="hover:text-brand-500">Inscrire mon entreprise</Link></li>
|
||||
<li><Link href="/blog" className="hover:text-brand-500">Actualités & Conseils</Link></li>
|
||||
<li><Link href="/actualites" className="hover:text-brand-500">Actualités & Conseils</Link></li>
|
||||
<li><Link href="/subscription" className="hover:text-brand-500">Tarifs</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
213
components/HomeSlider.tsx
Normal file
213
components/HomeSlider.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, MapPin, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface HomeSlide {
|
||||
id: string;
|
||||
type: 'BUSINESS' | 'CUSTOM';
|
||||
businessId?: string | null;
|
||||
business?: any;
|
||||
title?: string | null;
|
||||
subtitle?: string | null;
|
||||
imageUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
slides: HomeSlide[];
|
||||
onSearch: (searchTerm: string, locationTerm: string) => void;
|
||||
}
|
||||
|
||||
const HomeSlider = ({ slides, onSearch }: Props) => {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [locationTerm, setLocationTerm] = useState('');
|
||||
|
||||
// Auto-slide every 6 seconds
|
||||
useEffect(() => {
|
||||
if (slides.length <= 1) return;
|
||||
const interval = setInterval(() => {
|
||||
setCurrent((prev) => (prev === slides.length - 1 ? 0 : prev + 1));
|
||||
}, 6000);
|
||||
return () => clearInterval(interval);
|
||||
}, [slides.length]);
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearch(searchTerm, locationTerm);
|
||||
};
|
||||
|
||||
// If no slides, show default hero
|
||||
if (slides.length === 0) {
|
||||
return (
|
||||
<div className="relative bg-dark-900 overflow-hidden min-h-[600px] flex items-center">
|
||||
<div className="absolute inset-0 opacity-40">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80"
|
||||
className="w-full h-full object-cover"
|
||||
alt="Default Banner"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32 w-full">
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight animate-in fade-in slide-in-from-left-4 duration-700">
|
||||
Boostez votre visibilité dans <br />
|
||||
<span className="text-brand-500">l'écosystème africain</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 mb-8 max-w-2xl animate-in fade-in slide-in-from-left-4 duration-700 delay-150">
|
||||
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
|
||||
</p>
|
||||
<SearchForm
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
locationTerm={locationTerm}
|
||||
setLocationTerm={setLocationTerm}
|
||||
onSubmit={handleSearchSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-[650px] md:h-[700px] overflow-hidden bg-black">
|
||||
{slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
||||
index === current ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
||||
}`}
|
||||
>
|
||||
{/* Background Image */}
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={slide.imageUrl || slide.business?.coverUrl || slide.business?.logoUrl || ''}
|
||||
className={`w-full h-full object-cover transition-transform duration-[6000ms] ease-linear ${
|
||||
index === current ? 'scale-110' : 'scale-100'
|
||||
}`}
|
||||
alt=""
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/40 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative h-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center">
|
||||
<div className={`max-w-3xl transition-all duration-700 delay-300 ${
|
||||
index === current ? 'translate-x-0 opacity-100' : '-translate-x-10 opacity-0'
|
||||
}`}>
|
||||
{slide.type === 'BUSINESS' && (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 bg-brand-600/20 border border-brand-500/30 rounded-full mb-6">
|
||||
<span className="w-2 h-2 bg-brand-500 rounded-full animate-pulse" />
|
||||
<span className="text-brand-400 text-xs font-bold uppercase tracking-widest">Entreprise à la une</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight leading-tight">
|
||||
{slide.type === 'BUSINESS' ? (
|
||||
<>Découvrez <span className="text-brand-500">{slide.business?.name}</span></>
|
||||
) : (
|
||||
slide.title
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-300 mb-8 max-w-2xl line-clamp-3">
|
||||
{slide.type === 'BUSINESS' ? (
|
||||
slide.business?.description
|
||||
) : (
|
||||
slide.subtitle
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Link
|
||||
href={slide.type === 'BUSINESS' ? `/annuaire/${slide.business?.id}` : (slide.linkUrl || '#')}
|
||||
className="inline-flex items-center gap-2 bg-brand-600 text-white px-8 py-4 rounded-lg font-bold hover:bg-brand-700 transition-all shadow-lg shadow-brand-600/20 hover:scale-105"
|
||||
>
|
||||
{slide.type === 'BUSINESS' ? 'Voir la fiche' : 'En savoir plus'}
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Static Search Overlay (Always visible on top of slides) */}
|
||||
<div className="absolute bottom-12 md:bottom-20 left-0 right-0 z-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<SearchForm
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
locationTerm={locationTerm}
|
||||
setLocationTerm={setLocationTerm}
|
||||
onSubmit={handleSearchSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
{slides.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setCurrent(current === 0 ? slides.length - 1 : current - 1)}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-30 p-3 rounded-full bg-white/10 text-white hover:bg-white/20 transition-all backdrop-blur-sm"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrent(current === slides.length - 1 ? 0 : current + 1)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-30 p-3 rounded-full bg-white/10 text-white hover:bg-white/20 transition-all backdrop-blur-sm"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Indicators */}
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-30 flex gap-2">
|
||||
{slides.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrent(i)}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-all ${
|
||||
i === current ? 'bg-brand-500 w-8' : 'bg-white/40'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchForm = ({ searchTerm, setSearchTerm, locationTerm, setLocationTerm, onSubmit }: any) => (
|
||||
<form onSubmit={onSubmit} className="max-w-4xl bg-white/95 backdrop-blur-md p-2 rounded-xl shadow-2xl flex flex-col md:flex-row gap-2 border border-white/20 animate-in fade-in slide-in-from-bottom-4 duration-700 delay-500">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-3.5 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Que recherchez-vous ? (ex: Traiteur, Photographe...)"
|
||||
className="w-full pl-10 pr-4 py-3.5 rounded-lg focus:outline-none text-gray-900 font-medium"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-100">
|
||||
<MapPin className="absolute left-3 top-3.5 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Où ? (ex: Dakar)"
|
||||
className="w-full pl-10 pr-4 py-3.5 rounded-lg focus:outline-none text-gray-900 font-medium"
|
||||
value={locationTerm}
|
||||
onChange={(e) => setLocationTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-brand-600 text-white px-10 py-3.5 rounded-lg font-bold hover:bg-brand-700 transition-all hover:scale-105 active:scale-95 shadow-lg shadow-brand-600/30">
|
||||
Rechercher
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
export default HomeSlider;
|
||||
@@ -42,8 +42,8 @@ const Navbar = ({ settings }: NavbarProps) => {
|
||||
<Link href="/afrolife" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/afrolife') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-brand-600') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||
Afro Life
|
||||
</Link>
|
||||
<Link href="/blog" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/blog') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||
Blog
|
||||
<Link href="/actualites" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/actualites') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||
Actualités
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@ const Navbar = ({ settings }: NavbarProps) => {
|
||||
<Link href="/" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Accueil</Link>
|
||||
<Link href="/annuaire" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Annuaire</Link>
|
||||
<Link href="/afrolife" className="block pl-3 pr-4 py-2 border-l-4 border-brand-500 text-base font-medium text-brand-700 bg-brand-50" onClick={() => setIsOpen(false)}>Afro Life</Link>
|
||||
<Link href="/blog" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Blog</Link>
|
||||
<Link href="/actualites" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Actualités</Link>
|
||||
{user ? (
|
||||
<Link href="/dashboard" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Mon Espace</Link>
|
||||
) : (
|
||||
|
||||
251
components/OneShotPricingSection.tsx
Normal file
251
components/OneShotPricingSection.tsx
Normal file
@@ -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<OneShotPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPlan, setSelectedPlan] = useState<OneShotPlan | null>(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 (
|
||||
<div className="py-16 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-base font-semibold text-brand-600 tracking-wide uppercase">Services à la carte</h2>
|
||||
<p className="mt-1 text-3xl font-extrabold text-gray-900 sm:text-4xl font-serif">
|
||||
Boostez votre visibilité ponctuellement
|
||||
</p>
|
||||
<p className="max-w-xl mt-4 mx-auto text-lg text-gray-500">
|
||||
Des options flexibles pour mettre en avant vos actualités et vos offres sans engagement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{loading ? (
|
||||
[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-gray-50 rounded-2xl p-6 h-64 animate-pulse border border-gray-100">
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-xl mb-4"></div>
|
||||
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-8"></div>
|
||||
<div className="h-10 bg-gray-200 rounded w-full"></div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
plans.map((plan) => {
|
||||
const Icon = TYPE_ICONS[plan.type as keyof typeof TYPE_ICONS] || Zap;
|
||||
return (
|
||||
<div key={plan.id} className="bg-gray-50 rounded-2xl p-8 border border-gray-100 hover:border-brand-200 hover:shadow-lg transition-all group flex flex-col">
|
||||
<div className="w-14 h-14 bg-brand-50 rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||
<Icon className="w-8 h-8 text-brand-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 font-serif mb-2">{plan.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-6 flex-1">{plan.description}</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<span className="text-3xl font-black text-gray-900">{plan.priceXOF.toLocaleString()} F CFA</span>
|
||||
<p className="text-xs text-gray-400 mt-1">env. {plan.priceEUR} €</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleSelectPlan(plan)}
|
||||
className="w-full bg-white border-2 border-brand-600 text-brand-600 hover:bg-brand-600 hover:text-white font-bold py-3 rounded-xl transition-all shadow-sm"
|
||||
>
|
||||
Commander
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PAYMENT MODAL (Simplified copy from PricingSection) */}
|
||||
{isPaymentModalOpen && selectedPlan && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen px-4">
|
||||
<div className="fixed inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity" onClick={closePayment}></div>
|
||||
<div className="relative bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all max-w-lg w-full">
|
||||
<div className="px-6 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 font-serif">Paiement Service Ponctuel</h3>
|
||||
<button onClick={closePayment} className="text-gray-400 hover:text-gray-600"><X className="h-6 w-6" /></button>
|
||||
</div>
|
||||
|
||||
{paymentStep === 'method' && (
|
||||
<>
|
||||
<div className="bg-brand-50 p-5 rounded-2xl mb-8 border border-brand-100">
|
||||
<p className="text-sm text-brand-800 font-medium">Option : <span className="font-bold">{selectedPlan.name}</span></p>
|
||||
<p className="text-3xl font-black text-brand-900 mt-1">{selectedPlan.priceXOF.toLocaleString()} F CFA</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setPaymentMethod('mobile_money')}
|
||||
className={`w-full flex items-center p-4 border-2 rounded-xl transition-all ${paymentMethod === 'mobile_money' ? 'border-brand-500 bg-brand-50/50' : 'border-gray-100 hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="h-10 w-10 bg-orange-100 rounded-xl flex items-center justify-center text-orange-600 mr-4"><Smartphone /></div>
|
||||
<div className="text-left flex-1">
|
||||
<p className="font-bold text-gray-900">Mobile Money</p>
|
||||
<p className="text-xs text-gray-500">Orange, MTN, Wave, Moov</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPaymentMethod('card')}
|
||||
className={`w-full flex items-center p-4 border-2 rounded-xl transition-all ${paymentMethod === 'card' ? 'border-brand-500 bg-brand-50/50' : 'border-gray-100 hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="h-10 w-10 bg-blue-100 rounded-xl flex items-center justify-center text-blue-600 mr-4"><CreditCard /></div>
|
||||
<div className="text-left flex-1">
|
||||
<p className="font-bold text-gray-900">Carte Bancaire</p>
|
||||
<p className="text-xs text-gray-500">Visa, Mastercard</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={!paymentMethod}
|
||||
onClick={handlePayment}
|
||||
className="w-full mt-8 bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 disabled:opacity-50 transition-all"
|
||||
>
|
||||
Payer maintenant
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{paymentStep === 'processing' && (
|
||||
<div className="py-12 text-center">
|
||||
<Loader className="h-14 w-14 text-brand-600 animate-spin mx-auto mb-6" />
|
||||
<p className="text-xl font-bold text-gray-900">Sécurisation du paiement...</p>
|
||||
<p className="text-gray-500 mt-2">N'interrompez pas la transaction.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentStep === 'success' && (
|
||||
<div className="py-8 text-center animate-in zoom-in duration-300">
|
||||
<div className="mx-auto flex items-center justify-center h-20 w-20 rounded-full bg-green-100 mb-6">
|
||||
<ShieldCheck className="h-12 w-12 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900">Paiement Réussi !</h3>
|
||||
<p className="text-gray-500 mt-3 mb-8">
|
||||
{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.`
|
||||
}
|
||||
</p>
|
||||
{selectedPlan.type === 'POST_EVENT' || selectedPlan.type === 'POST_NEWS' ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsPaymentModalOpen(false);
|
||||
if (selectedPlan.type === 'POST_EVENT') setShowEventModal(true);
|
||||
if (selectedPlan.type === 'POST_NEWS') setShowNewsModal(true);
|
||||
}}
|
||||
className="w-full bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{selectedPlan.type === 'POST_EVENT' ? <Calendar className="w-5 h-5" /> : <Newspaper className="w-5 h-5" />}
|
||||
Remplir le formulaire
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={closePayment} className="w-full bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 transition-all">Retourner au site</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EVENT FORM MODAL */}
|
||||
<CreateEventModal
|
||||
isOpen={showEventModal}
|
||||
onClose={() => {
|
||||
setShowEventModal(false);
|
||||
closePayment();
|
||||
}}
|
||||
/>
|
||||
{/* NEWS FORM MODAL */}
|
||||
<CreateNewsModal
|
||||
isOpen={showNewsModal}
|
||||
onClose={() => {
|
||||
setShowNewsModal(false);
|
||||
closePayment();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OneShotPricingSection;
|
||||
229
components/dashboard/CreateEventModal.tsx
Normal file
229
components/dashboard/CreateEventModal.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-brand-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-100 rounded-xl text-brand-600">
|
||||
<Calendar className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Publier un événement</h2>
|
||||
<p className="text-xs text-gray-500">Partagez votre actualité avec la communauté</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-6 h-6 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 max-h-[80vh] overflow-y-auto">
|
||||
{/* Warning */}
|
||||
<div className="flex gap-4 p-4 bg-amber-50 border border-amber-200 rounded-2xl">
|
||||
<div className="shrink-0 p-2 bg-amber-100 rounded-xl text-amber-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-amber-800">Information importante</h4>
|
||||
<p className="text-xs text-amber-700 leading-relaxed">
|
||||
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).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Titre de l'événement *</label>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
placeholder="Ex: Conférence sur l'Agrotech"
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Date *</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
name="date"
|
||||
type="date"
|
||||
required
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Lieu *</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
name="location"
|
||||
required
|
||||
placeholder="Ex: Abidjan, Côte d'Ivoire"
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Description *</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Présentez votre événement en quelques lignes..."
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Affiche / Photo de l'événement</label>
|
||||
<div
|
||||
className={`relative border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-8 transition-all ${
|
||||
imagePreview ? 'border-brand-500 bg-brand-50' : 'border-gray-200 hover:border-brand-300 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{imagePreview ? (
|
||||
<div className="relative w-full aspect-video rounded-lg overflow-hidden shadow-inner">
|
||||
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImagePreview(null)}
|
||||
className="absolute top-2 right-2 p-1 bg-white/80 rounded-full hover:bg-white text-gray-600 shadow-lg"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
|
||||
)}
|
||||
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PNG, JPG jusqu'à 2Mo</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Lien externe (facultatif)</label>
|
||||
<div className="relative">
|
||||
<LinkIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
name="link"
|
||||
placeholder="https://votre-billetterie.com"
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-4 rounded-2xl font-bold text-gray-600 hover:bg-gray-100 transition-all"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || uploading}
|
||||
className="flex-[2] bg-brand-600 hover:bg-brand-700 disabled:bg-gray-200 disabled:cursor-not-allowed text-white px-6 py-4 rounded-2xl font-bold shadow-xl shadow-brand-200 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Soumission...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Calendar className="w-5 h-5" />
|
||||
Soumettre l'événement
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
components/dashboard/CreateNewsModal.tsx
Normal file
216
components/dashboard/CreateNewsModal.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-indigo-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-indigo-100 rounded-xl text-indigo-600">
|
||||
<Newspaper className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Publier une actualité</h2>
|
||||
<p className="text-xs text-gray-500">Mettez en avant vos succès et nouveautés</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-6 h-6 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 max-h-[80vh] overflow-y-auto">
|
||||
{/* Warning */}
|
||||
<div className="flex gap-4 p-4 bg-amber-50 border border-amber-200 rounded-2xl">
|
||||
<div className="shrink-0 p-2 bg-amber-100 rounded-xl text-amber-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-amber-800">Information importante</h4>
|
||||
<p className="text-xs text-amber-700 leading-relaxed">
|
||||
Votre article sera soumis à la validation de notre équipe de modération avant d'être publié dans la section Actualités.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Titre de l'article *</label>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
placeholder="Ex: Lancement de notre nouvelle gamme de produits"
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Auteur *</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
name="author"
|
||||
required
|
||||
placeholder="Votre nom ou nom d'entreprise"
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Résumé court (Excerpt) *</label>
|
||||
<div className="relative">
|
||||
<AlignLeft className="absolute left-4 top-4 w-4 h-4 text-gray-400" />
|
||||
<textarea
|
||||
name="excerpt"
|
||||
required
|
||||
rows={2}
|
||||
placeholder="Un court résumé qui apparaîtra dans la liste des articles..."
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Contenu de l'article *</label>
|
||||
<textarea
|
||||
name="content"
|
||||
required
|
||||
rows={6}
|
||||
placeholder="Racontez votre histoire..."
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Image de couverture</label>
|
||||
<div
|
||||
className={`relative border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-8 transition-all ${
|
||||
imagePreview ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-indigo-300 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{imagePreview ? (
|
||||
<div className="relative w-full aspect-video rounded-lg overflow-hidden shadow-inner">
|
||||
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImagePreview(null)}
|
||||
className="absolute top-2 right-2 p-1 bg-white/80 rounded-full hover:bg-white text-gray-600 shadow-lg"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
|
||||
)}
|
||||
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PNG, JPG jusqu'à 2Mo</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-4 rounded-2xl font-bold text-gray-600 hover:bg-gray-100 transition-all"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || uploading}
|
||||
className="flex-[2] bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-200 disabled:cursor-not-allowed text-white px-6 py-4 rounded-2xl font-bold shadow-xl shadow-indigo-200 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Soumission...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Newspaper className="w-5 h-5" />
|
||||
Soumettre l'actualité
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
/**
|
||||
* Sanitizes HTML content to prevent XSS attacks.
|
||||
* Use this before passing content to dangerouslySetInnerHTML.
|
||||
*/
|
||||
export function sanitizeHTML(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: [
|
||||
if (!html) return '';
|
||||
|
||||
// Remplacer les espaces insécables par des espaces normaux
|
||||
// Empêche le navigateur de considérer un paragraphe entier comme un seul mot
|
||||
const cleanHtml = html.replace(/ |\u00A0/g, ' ');
|
||||
|
||||
return sanitizeHtml(cleanHtml, {
|
||||
allowedTags: [
|
||||
'p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'span', 'div',
|
||||
'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt', 'class', 'style']
|
||||
allowedAttributes: {
|
||||
'a': ['href', 'target', 'rel'],
|
||||
'img': ['src', 'alt'],
|
||||
'*': ['class', 'style']
|
||||
},
|
||||
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
|
||||
// Standard URLs only
|
||||
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'tel'],
|
||||
});
|
||||
}
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
660
package-lock.json
generated
660
package-lock.json
generated
@@ -15,13 +15,12 @@
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"isomorphic-dompurify": "^3.11.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"next": "^16.1.6",
|
||||
@@ -33,6 +32,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"recharts": "^3.8.1",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2"
|
||||
@@ -57,199 +57,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
@@ -676,23 +483,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz",
|
||||
@@ -1918,15 +1708,6 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||
@@ -1974,11 +1755,14 @@
|
||||
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
"node_modules/@types/sanitize-html": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
|
||||
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"htmlparser2": "^10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
@@ -2098,15 +1882,6 @@
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
@@ -2359,19 +2134,6 @@
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -2520,19 +2282,6 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2550,18 +2299,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
@@ -2592,13 +2344,71 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
@@ -2667,18 +2477,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.46.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.0.tgz",
|
||||
@@ -2739,6 +2537,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
@@ -2959,16 +2769,35 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
@@ -3013,23 +2842,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isomorphic-dompurify": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.11.0.tgz",
|
||||
"integrity": "sha512-il0sNhLnfawc6vKoMqnZX1JXzEzw0pP3DZWg7mM7VJNJ8cq6DCxeAjEcfjTazyBF8dkUn+rBsBPS5NQs4ZaI3g==",
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.1",
|
||||
"jsdom": "^29.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
@@ -3041,46 +2860,6 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
@@ -3366,15 +3145,6 @@
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.554.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz",
|
||||
@@ -3393,12 +3163,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -3577,17 +3341,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
@@ -3851,15 +3609,6 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
@@ -4022,15 +3771,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
@@ -4085,16 +3825,18 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"license": "ISC",
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.3",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz",
|
||||
"integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
@@ -4259,12 +4001,6 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
|
||||
@@ -4299,48 +4035,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.29",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.29.tgz",
|
||||
"integrity": "sha512-JIXCerhudr/N6OWLwLF1HVsTTUo7ry6qHa5eWZEkiMuxsIiAACL55tGLfqfHfoH7QaMQUW8fngD7u7TxWexYQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.29"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.0.29",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.29.tgz",
|
||||
"integrity": "sha512-W99NuU7b1DcG3uJ3v9k9VztCH3WialNbBkBft5wCs8V8mexu0XQqaZEYb9l9RNNzK8+3EJ9PKWB0/RUtTQ/o+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
@@ -4389,15 +4083,6 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -4471,18 +4156,6 @@
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
@@ -4492,38 +4165,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
@@ -4563,21 +4204,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -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",
|
||||
@@ -28,13 +29,12 @@
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"isomorphic-dompurify": "^3.11.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"next": "^16.1.6",
|
||||
@@ -46,6 +46,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"recharts": "^3.8.1",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2"
|
||||
|
||||
@@ -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;\">© 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
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"admin"
|
||||
"admin",
|
||||
"scratch"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user