feat: implement comprehensive CMS dashboard for managing news, events, interviews, and one-shot pricing plans
This commit is contained in:
@@ -30,11 +30,11 @@ model User {
|
|||||||
businesses Business?
|
businesses Business?
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
conversations ConversationParticipant[]
|
conversations ConversationParticipant[]
|
||||||
|
favorites Favorite[]
|
||||||
sentMessages Message[]
|
sentMessages Message[]
|
||||||
reports MessageReport[]
|
reports MessageReport[]
|
||||||
ratings Rating[]
|
ratings Rating[]
|
||||||
country Country? @relation(fields: [countryId], references: [id])
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
favorites Favorite[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Business {
|
model Business {
|
||||||
@@ -68,8 +68,6 @@ model Business {
|
|||||||
websiteUrl String?
|
websiteUrl String?
|
||||||
isSuspended Boolean @default(false)
|
isSuspended Boolean @default(false)
|
||||||
suspensionReason String?
|
suspensionReason String?
|
||||||
metaTitle String?
|
|
||||||
metaDescription String?
|
|
||||||
plan Plan @default(STARTER)
|
plan Plan @default(STARTER)
|
||||||
city String?
|
city String?
|
||||||
countryId String?
|
countryId String?
|
||||||
@@ -78,14 +76,17 @@ model Business {
|
|||||||
coverZoom Float? @default(1.0)
|
coverZoom Float? @default(1.0)
|
||||||
suggestedCategory String?
|
suggestedCategory String?
|
||||||
categoryId String?
|
categoryId String?
|
||||||
|
metaDescription String?
|
||||||
|
metaTitle String?
|
||||||
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
|
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
|
||||||
country Country? @relation(fields: [countryId], references: [id])
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
conversations Conversation[]
|
conversations Conversation[]
|
||||||
|
favorites Favorite[]
|
||||||
|
homeSlides HomeSlide[]
|
||||||
offers Offer[]
|
offers Offer[]
|
||||||
ratings Rating[]
|
ratings Rating[]
|
||||||
favorites Favorite[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model BusinessCategory {
|
model BusinessCategory {
|
||||||
@@ -161,6 +162,21 @@ model BlogPost {
|
|||||||
status ContentStatus @default(PUBLISHED)
|
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 {
|
model Interview {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
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>")
|
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 {
|
model LegalDocument {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
type String @unique
|
type String @unique
|
||||||
@@ -332,14 +365,20 @@ model Favorite {
|
|||||||
userId String
|
userId String
|
||||||
businessId String
|
businessId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
business Business @relation(fields: [businessId], 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])
|
@@unique([userId, businessId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum HomeSlideType {
|
||||||
|
BUSINESS
|
||||||
|
CUSTOM
|
||||||
|
}
|
||||||
|
|
||||||
enum ContentStatus {
|
enum ContentStatus {
|
||||||
DRAFT
|
DRAFT
|
||||||
|
PENDING
|
||||||
PUBLISHED
|
PUBLISHED
|
||||||
ARCHIVED
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export async function createBlogPost(data: {
|
|||||||
status: data.status || ContentStatus.PUBLISHED,
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/actualites");
|
||||||
|
revalidatePath("/");
|
||||||
return { success: true, id: post.id };
|
return { success: true, id: post.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create blog post:", error);
|
console.error("Failed to create blog post:", error);
|
||||||
@@ -62,7 +63,8 @@ export async function updateBlogPost(id: string, data: {
|
|||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/actualites");
|
||||||
|
revalidatePath("/");
|
||||||
return { success: true, id: post.id };
|
return { success: true, id: post.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update blog post:", error);
|
console.error("Failed to update blog post:", error);
|
||||||
@@ -75,7 +77,8 @@ export async function deleteBlogPost(id: string) {
|
|||||||
await prisma.blogPost.delete({
|
await prisma.blogPost.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/actualites");
|
||||||
|
revalidatePath("/");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete blog post:", 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,8 @@ export async function searchBusinesses(query: string) {
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
logoUrl: true,
|
logoUrl: true,
|
||||||
|
coverUrl: true,
|
||||||
|
description: true,
|
||||||
location: true,
|
location: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import BlogForm from "@/components/BlogForm";
|
import ActualitesForm from "@/components/ActualitesForm";
|
||||||
import { notFound } from "next/navigation";
|
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 { id } = await params;
|
||||||
const post = await prisma.blogPost.findUnique({
|
const post = await prisma.blogPost.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -12,5 +12,5 @@ export default async function EditBlogPage({ params }: { params: Promise<{ id: s
|
|||||||
notFound();
|
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 { prisma } from '@/lib/prisma';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin } from 'lucide-react';
|
import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin, Clock, ShieldCheck, ShieldAlert } from 'lucide-react';
|
||||||
import DeleteBlogButton from '@/components/DeleteBlogButton';
|
import DeleteActualitesButton from '@/components/DeleteActualitesButton';
|
||||||
import DeleteInterviewButton from '@/components/DeleteInterviewButton';
|
import DeleteInterviewButton from '@/components/DeleteInterviewButton';
|
||||||
import DeleteEventButton from '@/components/DeleteEventButton';
|
import DeleteEventButton from '@/components/DeleteEventButton';
|
||||||
|
import ApproveContentButton from '@/components/ApproveContentButton';
|
||||||
|
|
||||||
async function getData() {
|
async function getData() {
|
||||||
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
|
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
|
||||||
@@ -14,25 +15,30 @@ async function getData() {
|
|||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
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 '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 '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;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogCMSPage() {
|
export default async function ActualitesCMSPage() {
|
||||||
const { posts, interviews, events } = await getData();
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex justify-between items-end mb-8">
|
<div className="flex justify-between items-end mb-8">
|
||||||
<div>
|
<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>
|
<p className="text-slate-400">Gérez le contenu éditorial et événementiel de la plateforme.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-4">
|
<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" />
|
<Plus className="w-4 h-4" />
|
||||||
Nouvel Article
|
Nouvel Article
|
||||||
</Link>
|
</Link>
|
||||||
@@ -47,12 +53,77 @@ export default async function BlogCMSPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
{/* Blog Posts Section */}
|
{/* Blog Posts Section */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<BookOpen className="text-indigo-400 w-5 h-5" />
|
<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>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="data-table w-full">
|
<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-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
|
||||||
<td className="py-4 px-4 text-right">
|
<td className="py-4 px-4 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<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" />
|
<Edit className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<DeleteBlogButton id={post.id} />
|
<DeleteActualitesButton id={post.id} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 React, { useState, useEffect } from 'react';
|
||||||
import { getPricingPlans } from '@/app/actions/plans';
|
import { getPricingPlans } from '@/app/actions/plans';
|
||||||
|
import { getOneShotPlans } from '@/app/actions/one-shot';
|
||||||
import { CreditCard, Edit3, CheckCircle2, Star, Loader2, Zap } from 'lucide-react';
|
import { CreditCard, Edit3, CheckCircle2, Star, Loader2, Zap } from 'lucide-react';
|
||||||
import PlanEditModal from '@/components/PlanEditModal';
|
import PlanEditModal from '@/components/PlanEditModal';
|
||||||
|
import OneShotPricingSettings from '@/components/OneShotPricingSettings';
|
||||||
|
|
||||||
export default function PlansPage() {
|
export default function PlansPage() {
|
||||||
const [plans, setPlans] = useState<any[]>([]);
|
const [plans, setPlans] = useState<any[]>([]);
|
||||||
|
const [oneShotPlans, setOneShotPlans] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingPlan, setEditingPlan] = useState<any>(null);
|
const [editingPlan, setEditingPlan] = useState<any>(null);
|
||||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadPlans() {
|
async function loadPlans() {
|
||||||
const data = await getPricingPlans();
|
try {
|
||||||
setPlans(data);
|
const [plansData, oneShotData] = await Promise.all([
|
||||||
setLoading(false);
|
getPricingPlans(),
|
||||||
|
getOneShotPlans()
|
||||||
|
]);
|
||||||
|
setPlans(plansData);
|
||||||
|
setOneShotPlans(oneShotData || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load plans:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loadPlans();
|
loadPlans();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -32,8 +44,8 @@ export default function PlansPage() {
|
|||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Tarifs & Forfaits</h1>
|
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Forfaits & Services</h1>
|
||||||
<p className="text-slate-400">Modifiez les noms, prix et limites d'offres affichés sur le site.</p>
|
<p className="text-slate-400">Modifiez les abonnements mensuels et les services ponctuels (One-Shot).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle Billing */}
|
{/* Toggle Billing */}
|
||||||
@@ -129,6 +141,11 @@ export default function PlansPage() {
|
|||||||
currentCycle={billingCycle}
|
currentCycle={billingCycle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* One Shot Plans Section */}
|
||||||
|
<div className="mt-16 pt-16 border-t border-slate-800">
|
||||||
|
<OneShotPricingSettings initialPlans={oneShotPlans} />
|
||||||
|
</div>
|
||||||
</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 { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase } from 'lucide-react';
|
||||||
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
|
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { getBlogPosts } from '@/app/actions/blog';
|
import { getBlogPosts } from '@/app/actions/actualites';
|
||||||
import RichTextEditor from '@/components/RichTextEditor';
|
import RichTextEditor from '@/components/RichTextEditor';
|
||||||
|
|
||||||
const BUSINESS_CATEGORIES = [
|
const BUSINESS_CATEGORIES = [
|
||||||
@@ -35,9 +35,9 @@ const BLOG_CATEGORIES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsPage({
|
export default function SettingsPage({
|
||||||
searchParams
|
searchParams
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ tab?: string }>
|
searchParams: Promise<{ tab?: string }>
|
||||||
}) {
|
}) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [settings, setSettings] = useState<any>(null);
|
const [settings, setSettings] = useState<any>(null);
|
||||||
@@ -55,18 +55,24 @@ export default function SettingsPage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const [settingsData, postsData] = await Promise.all([
|
try {
|
||||||
getSiteSettings(),
|
const [settingsData, postsData] = await Promise.all([
|
||||||
getBlogPosts()
|
getSiteSettings(),
|
||||||
]);
|
getBlogPosts()
|
||||||
setSettings(settingsData);
|
]);
|
||||||
setAllPosts(postsData);
|
setSettings(settingsData);
|
||||||
setSelectedPostIds(settingsData?.homeBlogIds || []);
|
setAllPosts(postsData);
|
||||||
setSelectedHomeCategories(settingsData?.homeCategories || []);
|
setSelectedPostIds(settingsData?.homeBlogIds || []);
|
||||||
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
|
setSelectedHomeCategories(settingsData?.homeCategories || []);
|
||||||
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || '');
|
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
|
||||||
setVerifyEmailTemplate(settingsData?.verifyEmailTemplate || '');
|
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || '');
|
||||||
setLoading(false);
|
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();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -147,18 +153,18 @@ export default function SettingsPage({
|
|||||||
{/* Tabs Switcher */}
|
{/* Tabs Switcher */}
|
||||||
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentTab('general')}
|
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'}`}
|
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" />
|
<Globe className="w-4 h-4" />
|
||||||
Général
|
Général
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentTab('emails')}
|
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'}`}
|
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" />
|
<Mail className="w-4 h-4" />
|
||||||
Configuration Mail
|
Configuration Mail
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -213,11 +219,10 @@ export default function SettingsPage({
|
|||||||
key={cat}
|
key={cat}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleHomeCategory(cat)}
|
onClick={() => toggleHomeCategory(cat)}
|
||||||
className={`text-left p-2 rounded-lg border text-xs transition-all ${
|
className={`text-left p-2 rounded-lg border text-xs transition-all ${selectedHomeCategories.includes(cat)
|
||||||
selectedHomeCategories.includes(cat)
|
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
|
||||||
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
|
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||||
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
@@ -305,11 +310,10 @@ export default function SettingsPage({
|
|||||||
key={post.id}
|
key={post.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => togglePostId(post.id)}
|
onClick={() => togglePostId(post.id)}
|
||||||
className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${
|
className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${selectedPostIds.includes(post.id)
|
||||||
selectedPostIds.includes(post.id)
|
? 'bg-indigo-600/20 border-indigo-500/50 text-white'
|
||||||
? 'bg-indigo-600/20 border-indigo-500/50 text-white'
|
: 'text-slate-400 hover:bg-slate-900'
|
||||||
: 'text-slate-400 hover:bg-slate-900'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span>{post.title}</span>
|
<span>{post.title}</span>
|
||||||
{selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</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}
|
key={cat}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleBlogCategory(cat)}
|
onClick={() => toggleBlogCategory(cat)}
|
||||||
className={`px-4 py-2 rounded-full border text-xs transition-all ${
|
className={`px-4 py-2 rounded-full border text-xs transition-all ${selectedBlogCategories.includes(cat)
|
||||||
selectedBlogCategories.includes(cat)
|
? 'bg-purple-600 border-purple-500 text-white'
|
||||||
? 'bg-purple-600 border-purple-500 text-white'
|
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||||
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import SlideForm from "@/components/SlideForm";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
export default async function EditSlidePage({ params }: { params: { id: string } }) {
|
export default async function EditSlidePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
const slide = await prisma.homeSlide.findUnique({
|
const slide = await prisma.homeSlide.findUnique({
|
||||||
where: { id: params.id },
|
where: { id },
|
||||||
include: { business: true }
|
include: { business: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useTransition, useState, useEffect } from 'react';
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
@@ -79,7 +79,7 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
|
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
|
||||||
router.push('/blog');
|
router.push('/actualites');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Une erreur est survenue");
|
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="max-w-4xl mx-auto pb-20">
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-white">
|
<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";
|
"use client";
|
||||||
|
|
||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
import { deleteBlogPost } from '@/app/actions/blog';
|
import { deleteBlogPost } from '@/app/actions/actualites';
|
||||||
import { Trash2, Loader2 } from 'lucide-react';
|
import { Trash2, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
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 [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
@@ -67,7 +67,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(initialData ? "Événement mis à jour" : "Événement créé avec succès");
|
toast.success(initialData ? "Événement mis à jour" : "Événement créé avec succès");
|
||||||
router.push('/blog');
|
router.push('/actualites');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Une erreur est survenue");
|
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="max-w-4xl mx-auto pb-20">
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-white">
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function ImageUploader({ value, onChange, label, placeholder, nam
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name={name}
|
name={name}
|
||||||
value={value}
|
value={value || ""}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder || "https://images.unsplash.com/..."}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Hidden input for value to be sent via form if needed */}
|
{/* 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(initialData ? "Interview mise à jour" : "Interview créée avec succès");
|
toast.success(initialData ? "Interview mise à jour" : "Interview créée avec succès");
|
||||||
router.push('/blog');
|
router.push('/actualites');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Une erreur est survenue");
|
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="max-w-4xl mx-auto pb-20">
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<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" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-white">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,8 +26,8 @@ const menuItems = [
|
|||||||
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
||||||
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
||||||
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
||||||
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
{ name: 'Actualités & Interviews', icon: BookOpen, href: '/actualites' },
|
||||||
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
|
{ name: 'Forfaits & Services', icon: CreditCard, href: '/plans' },
|
||||||
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
|
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
|
||||||
{ name: 'Pays', icon: Globe, href: '/countries' },
|
{ name: 'Pays', icon: Globe, href: '/countries' },
|
||||||
{ name: 'Bannière', icon: ImageIcon, href: '/slides' },
|
{ name: 'Bannière', icon: ImageIcon, href: '/slides' },
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function SlideForm({ initialData }: Props) {
|
|||||||
businessId: type === 'BUSINESS' ? selectedBusiness?.id : null,
|
businessId: type === 'BUSINESS' ? selectedBusiness?.id : null,
|
||||||
title: formData.get('title') as string,
|
title: formData.get('title') as string,
|
||||||
subtitle: formData.get('subtitle') as string,
|
subtitle: formData.get('subtitle') as string,
|
||||||
imageUrl: type === 'CUSTOM' ? imageUrl : (selectedBusiness?.logoUrl || ''),
|
imageUrl: type === 'CUSTOM' ? imageUrl : (selectedBusiness?.coverUrl || selectedBusiness?.logoUrl || ''),
|
||||||
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
|
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
|
||||||
order: parseInt(formData.get('order') as string) || 0,
|
order: parseInt(formData.get('order') as string) || 0,
|
||||||
isActive,
|
isActive,
|
||||||
@@ -148,8 +148,9 @@ export default function SlideForm({ initialData }: Props) {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
<Search className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
|
key="search-query-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery || ""}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Tapez le nom d'une entreprise..."
|
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"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
@@ -196,8 +197,9 @@ export default function SlideForm({ initialData }: Props) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Titre</label>
|
<label className="text-sm font-medium text-slate-400">Titre</label>
|
||||||
<input
|
<input
|
||||||
|
key="custom-title-input"
|
||||||
name="title"
|
name="title"
|
||||||
defaultValue={initialData?.title}
|
defaultValue={initialData?.title || ""}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
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"
|
placeholder="Ex: Découvrez l'artisanat local"
|
||||||
/>
|
/>
|
||||||
@@ -205,8 +207,9 @@ export default function SlideForm({ initialData }: Props) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
|
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
|
||||||
<input
|
<input
|
||||||
|
key="custom-subtitle-input"
|
||||||
name="subtitle"
|
name="subtitle"
|
||||||
defaultValue={initialData?.subtitle}
|
defaultValue={initialData?.subtitle || ""}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
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"
|
placeholder="Ex: Des produits uniques faits main"
|
||||||
/>
|
/>
|
||||||
@@ -216,8 +219,9 @@ export default function SlideForm({ initialData }: Props) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Lien (URL)</label>
|
<label className="text-sm font-medium text-slate-400">Lien (URL)</label>
|
||||||
<input
|
<input
|
||||||
|
key="custom-link-input"
|
||||||
name="linkUrl"
|
name="linkUrl"
|
||||||
defaultValue={initialData?.linkUrl}
|
defaultValue={initialData?.linkUrl || ""}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
|
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://..."
|
placeholder="Ex: /annuaire ou https://..."
|
||||||
/>
|
/>
|
||||||
@@ -240,9 +244,10 @@ export default function SlideForm({ initialData }: Props) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Ordre d'affichage</label>
|
<label className="text-sm font-medium text-slate-400">Ordre d'affichage</label>
|
||||||
<input
|
<input
|
||||||
|
key="order-input"
|
||||||
name="order"
|
name="order"
|
||||||
type="number"
|
type="number"
|
||||||
defaultValue={initialData?.order || 0}
|
defaultValue={initialData?.order ?? 0}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
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"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { PrismaPg } from '@prisma/adapter-pg'
|
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'
|
import pg from 'pg'
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL!
|
const connectionString = process.env.DATABASE_URL!
|
||||||
@@ -11,10 +11,10 @@ function makePrisma() {
|
|||||||
return new PrismaClient({ adapter })
|
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
|
export default prisma
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialS
|
|||||||
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
|
<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>
|
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/blog" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
|
<Link href="/actualites" 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" />
|
Voir les actualités <ArrowRight className="w-4 h-4 ml-1" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialS
|
|||||||
})
|
})
|
||||||
.slice(0, settings?.homeBlogCount || 3)
|
.slice(0, settings?.homeBlogCount || 3)
|
||||||
.map(post => (
|
.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">
|
<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" />
|
<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>
|
</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é" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,7 +110,7 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<Link href="/blog" className="inline-flex items-center text-gray-500 hover:text-brand-600 text-sm font-medium transition-colors">
|
<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
|
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +172,7 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
<div className="mt-12 pt-8 border-t border-gray-100 flex flex-col sm:flex-row justify-between items-center gap-4">
|
<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>
|
<p className="text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
||||||
<Link
|
<Link
|
||||||
href="/blog"
|
href="/actualites"
|
||||||
className="inline-flex items-center text-brand-600 hover:text-brand-700 font-semibold transition-colors"
|
className="inline-flex items-center text-brand-600 hover:text-brand-700 font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
Lire d'autres articles
|
Lire d'autres articles
|
||||||
@@ -199,7 +199,7 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
{relatedPosts.map((rPost) => (
|
{relatedPosts.map((rPost) => (
|
||||||
<Link
|
<Link
|
||||||
key={rPost.id}
|
key={rPost.id}
|
||||||
href={`/blog/${rPost.slug || 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"
|
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">
|
<div className="aspect-video relative overflow-hidden">
|
||||||
@@ -26,9 +26,9 @@ export default async function BlogPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="text-center mb-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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export default async function BlogPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-8 lg:grid-cols-3">
|
<div className="grid gap-8 lg:grid-cols-3">
|
||||||
{posts.map(post => (
|
{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">
|
<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} />
|
<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>
|
<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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import DashboardProfileClient from '../../components/dashboard/DashboardProfileC
|
|||||||
import DashboardMessages from '../../components/dashboard/DashboardMessages';
|
import DashboardMessages from '../../components/dashboard/DashboardMessages';
|
||||||
import DashboardOffers from '../../components/dashboard/DashboardOffers';
|
import DashboardOffers from '../../components/dashboard/DashboardOffers';
|
||||||
import PricingSection from '../../components/PricingSection';
|
import PricingSection from '../../components/PricingSection';
|
||||||
|
import OneShotPricingSection from '../../components/OneShotPricingSection';
|
||||||
import DashboardFavorites from '../../components/dashboard/DashboardFavorites';
|
import DashboardFavorites from '../../components/dashboard/DashboardFavorites';
|
||||||
import { useUser } from '../../components/UserProvider';
|
import { useUser } from '../../components/UserProvider';
|
||||||
|
|
||||||
@@ -242,7 +243,7 @@ const DashboardContent = () => {
|
|||||||
</button>
|
</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`}>
|
<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`} />
|
<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>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -326,6 +327,7 @@ const DashboardContent = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<PricingSection />
|
<PricingSection />
|
||||||
|
<OneShotPricingSection />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PricingSection from '../../components/PricingSection';
|
import PricingSection from '../../components/PricingSection';
|
||||||
|
import OneShotPricingSection from '../../components/OneShotPricingSection';
|
||||||
|
|
||||||
const SubscriptionPage = () => {
|
const SubscriptionPage = () => {
|
||||||
return (
|
return (
|
||||||
@@ -12,6 +13,7 @@ const SubscriptionPage = () => {
|
|||||||
<p className="text-gray-400 mt-2">Rejoignez la plus grande communauté d'entrepreneurs.</p>
|
<p className="text-gray-400 mt-2">Rejoignez la plus grande communauté d'entrepreneurs.</p>
|
||||||
</div>
|
</div>
|
||||||
<PricingSection />
|
<PricingSection />
|
||||||
|
<OneShotPricingSection />
|
||||||
|
|
||||||
{/* FAQ Section */}
|
{/* FAQ Section */}
|
||||||
<div className="max-w-4xl mx-auto px-4 py-16">
|
<div className="max-w-4xl mx-auto px-4 py-16">
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const Footer = ({ settings }: FooterProps) => {
|
|||||||
<ul className="space-y-2 text-gray-400 text-sm">
|
<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="/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="/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>
|
<li><Link href="/subscription" className="hover:text-brand-500">Tarifs</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const HomeSlider = ({ slides, onSearch }: Props) => {
|
|||||||
{/* Background Image */}
|
{/* Background Image */}
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<img
|
<img
|
||||||
src={slide.type === 'BUSINESS' ? (slide.business?.coverUrl || slide.business?.logoUrl) : (slide.imageUrl || '')}
|
src={slide.imageUrl || slide.business?.coverUrl || slide.business?.logoUrl || ''}
|
||||||
className={`w-full h-full object-cover transition-transform duration-[6000ms] ease-linear ${
|
className={`w-full h-full object-cover transition-transform duration-[6000ms] ease-linear ${
|
||||||
index === current ? 'scale-110' : 'scale-100'
|
index === current ? 'scale-110' : 'scale-100'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -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')}`}>
|
<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
|
Afro Life
|
||||||
</Link>
|
</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')}`}>
|
<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')}`}>
|
||||||
Blog
|
Actualités
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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="/" 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="/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="/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 ? (
|
{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>
|
<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 })
|
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
|
// Updated: 2026-05-10T17:21 (Forced reload for OneShotPlan model)
|
||||||
export const prisma = globalForPrisma.prisma || makePrisma()
|
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
|
export default prisma
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"all": "concurrently \"npm run dev\" \"npm run dev:admin\"",
|
"all": "concurrently \"npm run dev\" \"npm run dev:admin\"",
|
||||||
"build": "prisma generate && next build && npm run build:admin",
|
"build": "prisma generate && next build && npm run build:admin",
|
||||||
"build:admin": "npm install --prefix admin && prisma generate && npm run build --prefix 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": "npx prisma migrate deploy && next start",
|
||||||
"start:admin": "npm run start --prefix admin",
|
"start:admin": "npm run start --prefix admin",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
|
|||||||
@@ -317,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>")
|
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 {
|
model LegalDocument {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
type String @unique
|
type String @unique
|
||||||
@@ -361,6 +378,7 @@ enum HomeSlideType {
|
|||||||
|
|
||||||
enum ContentStatus {
|
enum ContentStatus {
|
||||||
DRAFT
|
DRAFT
|
||||||
|
PENDING
|
||||||
PUBLISHED
|
PUBLISHED
|
||||||
ARCHIVED
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|||||||
7
scratch/debug-prisma.ts
Normal file
7
scratch/debug-prisma.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { prisma } from './admin/src/lib/prisma';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
console.log('Prisma keys:', Object.keys(prisma));
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
Reference in New Issue
Block a user