feat: implement comprehensive CMS dashboard for managing news, events, interviews, and one-shot pricing plans
This commit is contained in:
@@ -67,7 +67,7 @@ const Footer = ({ settings }: FooterProps) => {
|
||||
<ul className="space-y-2 text-gray-400 text-sm">
|
||||
<li><Link href="/annuaire" className="hover:text-brand-500">Rechercher une entreprise</Link></li>
|
||||
<li><Link href="/login" className="hover:text-brand-500">Inscrire mon entreprise</Link></li>
|
||||
<li><Link href="/blog" className="hover:text-brand-500">Actualités & Conseils</Link></li>
|
||||
<li><Link href="/actualites" className="hover:text-brand-500">Actualités & Conseils</Link></li>
|
||||
<li><Link href="/subscription" className="hover:text-brand-500">Tarifs</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,7 @@ const HomeSlider = ({ slides, onSearch }: Props) => {
|
||||
{/* Background Image */}
|
||||
<div className="absolute inset-0">
|
||||
<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 ${
|
||||
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')}`}>
|
||||
Afro Life
|
||||
</Link>
|
||||
<Link href="/blog" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/blog') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||
Blog
|
||||
<Link href="/actualites" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/actualites') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||
Actualités
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@ const Navbar = ({ settings }: NavbarProps) => {
|
||||
<Link href="/" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Accueil</Link>
|
||||
<Link href="/annuaire" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Annuaire</Link>
|
||||
<Link href="/afrolife" className="block pl-3 pr-4 py-2 border-l-4 border-brand-500 text-base font-medium text-brand-700 bg-brand-50" onClick={() => setIsOpen(false)}>Afro Life</Link>
|
||||
<Link href="/blog" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Blog</Link>
|
||||
<Link href="/actualites" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Actualités</Link>
|
||||
{user ? (
|
||||
<Link href="/dashboard" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Mon Espace</Link>
|
||||
) : (
|
||||
|
||||
251
components/OneShotPricingSection.tsx
Normal file
251
components/OneShotPricingSection.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Zap, Calendar, Newspaper, Layout, Check, Smartphone, CreditCard, Loader, ShieldCheck, X } from 'lucide-react';
|
||||
import { useUser } from './UserProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import CreateEventModal from './dashboard/CreateEventModal';
|
||||
import CreateNewsModal from './dashboard/CreateNewsModal';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface OneShotPlan {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
priceXOF: number;
|
||||
priceEUR: number;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
const TYPE_ICONS = {
|
||||
BUMP: Zap,
|
||||
POST_EVENT: Calendar,
|
||||
POST_NEWS: Newspaper,
|
||||
AD_DIRECTORY: Layout,
|
||||
};
|
||||
|
||||
const OneShotPricingSection = () => {
|
||||
const [plans, setPlans] = useState<OneShotPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPlan, setSelectedPlan] = useState<OneShotPlan | null>(null);
|
||||
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
|
||||
const [paymentStep, setPaymentStep] = useState<'method' | 'processing' | 'success'>('method');
|
||||
const [paymentMethod, setPaymentMethod] = useState<'mobile_money' | 'card' | null>(null);
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showNewsModal, setShowNewsModal] = useState(false);
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/one-shot-plans');
|
||||
const data = await res.json();
|
||||
if (!data.error) {
|
||||
setPlans(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching one-shot plans:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
const handleSelectPlan = (plan: OneShotPlan) => {
|
||||
if (!user) {
|
||||
toast.error("Veuillez vous connecter pour commander un service");
|
||||
router.push('/login?callback=/subscription');
|
||||
return;
|
||||
}
|
||||
setSelectedPlan(plan);
|
||||
setPaymentStep('method');
|
||||
setIsPaymentModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePayment = () => {
|
||||
setPaymentStep('processing');
|
||||
setTimeout(() => {
|
||||
setPaymentStep('success');
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const closePayment = () => {
|
||||
setIsPaymentModalOpen(false);
|
||||
setPaymentStep('method');
|
||||
setSelectedPlan(null);
|
||||
setPaymentMethod(null);
|
||||
};
|
||||
|
||||
if (!loading && plans.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="py-16 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-base font-semibold text-brand-600 tracking-wide uppercase">Services à la carte</h2>
|
||||
<p className="mt-1 text-3xl font-extrabold text-gray-900 sm:text-4xl font-serif">
|
||||
Boostez votre visibilité ponctuellement
|
||||
</p>
|
||||
<p className="max-w-xl mt-4 mx-auto text-lg text-gray-500">
|
||||
Des options flexibles pour mettre en avant vos actualités et vos offres sans engagement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{loading ? (
|
||||
[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-gray-50 rounded-2xl p-6 h-64 animate-pulse border border-gray-100">
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-xl mb-4"></div>
|
||||
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-8"></div>
|
||||
<div className="h-10 bg-gray-200 rounded w-full"></div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
plans.map((plan) => {
|
||||
const Icon = TYPE_ICONS[plan.type as keyof typeof TYPE_ICONS] || Zap;
|
||||
return (
|
||||
<div key={plan.id} className="bg-gray-50 rounded-2xl p-8 border border-gray-100 hover:border-brand-200 hover:shadow-lg transition-all group flex flex-col">
|
||||
<div className="w-14 h-14 bg-brand-50 rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||
<Icon className="w-8 h-8 text-brand-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 font-serif mb-2">{plan.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-6 flex-1">{plan.description}</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<span className="text-3xl font-black text-gray-900">{plan.priceXOF.toLocaleString()} F CFA</span>
|
||||
<p className="text-xs text-gray-400 mt-1">env. {plan.priceEUR} €</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleSelectPlan(plan)}
|
||||
className="w-full bg-white border-2 border-brand-600 text-brand-600 hover:bg-brand-600 hover:text-white font-bold py-3 rounded-xl transition-all shadow-sm"
|
||||
>
|
||||
Commander
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PAYMENT MODAL (Simplified copy from PricingSection) */}
|
||||
{isPaymentModalOpen && selectedPlan && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen px-4">
|
||||
<div className="fixed inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity" onClick={closePayment}></div>
|
||||
<div className="relative bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all max-w-lg w-full">
|
||||
<div className="px-6 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 font-serif">Paiement Service Ponctuel</h3>
|
||||
<button onClick={closePayment} className="text-gray-400 hover:text-gray-600"><X className="h-6 w-6" /></button>
|
||||
</div>
|
||||
|
||||
{paymentStep === 'method' && (
|
||||
<>
|
||||
<div className="bg-brand-50 p-5 rounded-2xl mb-8 border border-brand-100">
|
||||
<p className="text-sm text-brand-800 font-medium">Option : <span className="font-bold">{selectedPlan.name}</span></p>
|
||||
<p className="text-3xl font-black text-brand-900 mt-1">{selectedPlan.priceXOF.toLocaleString()} F CFA</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setPaymentMethod('mobile_money')}
|
||||
className={`w-full flex items-center p-4 border-2 rounded-xl transition-all ${paymentMethod === 'mobile_money' ? 'border-brand-500 bg-brand-50/50' : 'border-gray-100 hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="h-10 w-10 bg-orange-100 rounded-xl flex items-center justify-center text-orange-600 mr-4"><Smartphone /></div>
|
||||
<div className="text-left flex-1">
|
||||
<p className="font-bold text-gray-900">Mobile Money</p>
|
||||
<p className="text-xs text-gray-500">Orange, MTN, Wave, Moov</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPaymentMethod('card')}
|
||||
className={`w-full flex items-center p-4 border-2 rounded-xl transition-all ${paymentMethod === 'card' ? 'border-brand-500 bg-brand-50/50' : 'border-gray-100 hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="h-10 w-10 bg-blue-100 rounded-xl flex items-center justify-center text-blue-600 mr-4"><CreditCard /></div>
|
||||
<div className="text-left flex-1">
|
||||
<p className="font-bold text-gray-900">Carte Bancaire</p>
|
||||
<p className="text-xs text-gray-500">Visa, Mastercard</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={!paymentMethod}
|
||||
onClick={handlePayment}
|
||||
className="w-full mt-8 bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 disabled:opacity-50 transition-all"
|
||||
>
|
||||
Payer maintenant
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{paymentStep === 'processing' && (
|
||||
<div className="py-12 text-center">
|
||||
<Loader className="h-14 w-14 text-brand-600 animate-spin mx-auto mb-6" />
|
||||
<p className="text-xl font-bold text-gray-900">Sécurisation du paiement...</p>
|
||||
<p className="text-gray-500 mt-2">N'interrompez pas la transaction.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentStep === 'success' && (
|
||||
<div className="py-8 text-center animate-in zoom-in duration-300">
|
||||
<div className="mx-auto flex items-center justify-center h-20 w-20 rounded-full bg-green-100 mb-6">
|
||||
<ShieldCheck className="h-12 w-12 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900">Paiement Réussi !</h3>
|
||||
<p className="text-gray-500 mt-3 mb-8">
|
||||
{selectedPlan.type === 'POST_EVENT' || selectedPlan.type === 'POST_NEWS'
|
||||
? "Votre paiement a été validé. Vous pouvez maintenant remplir le formulaire de votre publication."
|
||||
: `Votre service ${selectedPlan.name} a été activé avec succès.`
|
||||
}
|
||||
</p>
|
||||
{selectedPlan.type === 'POST_EVENT' || selectedPlan.type === 'POST_NEWS' ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsPaymentModalOpen(false);
|
||||
if (selectedPlan.type === 'POST_EVENT') setShowEventModal(true);
|
||||
if (selectedPlan.type === 'POST_NEWS') setShowNewsModal(true);
|
||||
}}
|
||||
className="w-full bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{selectedPlan.type === 'POST_EVENT' ? <Calendar className="w-5 h-5" /> : <Newspaper className="w-5 h-5" />}
|
||||
Remplir le formulaire
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={closePayment} className="w-full bg-brand-600 text-white font-bold py-4 rounded-xl shadow-lg hover:bg-brand-700 transition-all">Retourner au site</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EVENT FORM MODAL */}
|
||||
<CreateEventModal
|
||||
isOpen={showEventModal}
|
||||
onClose={() => {
|
||||
setShowEventModal(false);
|
||||
closePayment();
|
||||
}}
|
||||
/>
|
||||
{/* NEWS FORM MODAL */}
|
||||
<CreateNewsModal
|
||||
isOpen={showNewsModal}
|
||||
onClose={() => {
|
||||
setShowNewsModal(false);
|
||||
closePayment();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OneShotPricingSection;
|
||||
229
components/dashboard/CreateEventModal.tsx
Normal file
229
components/dashboard/CreateEventModal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useTransition } from 'react';
|
||||
import { X, Calendar, MapPin, Link as LinkIcon, Image as ImageIcon, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { createEvent } from '@/app/actions/events';
|
||||
import { uploadImage } from '@/app/actions/upload';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface CreateEventModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CreateEventModal({ isOpen, onClose }: CreateEventModalProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
const data = {
|
||||
title: formData.get('title') as string,
|
||||
description: formData.get('description') as string,
|
||||
date: formData.get('date') as string,
|
||||
location: formData.get('location') as string,
|
||||
thumbnailUrl: imagePreview || "https://picsum.photos/800/400?random=event",
|
||||
link: formData.get('link') as string || undefined,
|
||||
};
|
||||
|
||||
if (!data.title || !data.description || !data.date || !data.location) {
|
||||
toast.error("Veuillez remplir tous les champs obligatoires");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createEvent(data);
|
||||
if (result.success) {
|
||||
toast.success("Événement soumis avec succès !");
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(result.error || "Une erreur est survenue");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const result = await uploadImage(formData);
|
||||
if (result.success && result.url) {
|
||||
setImagePreview(result.url);
|
||||
} else {
|
||||
toast.error(result.error || "Erreur lors de l'upload");
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-brand-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-100 rounded-xl text-brand-600">
|
||||
<Calendar className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Publier un événement</h2>
|
||||
<p className="text-xs text-gray-500">Partagez votre actualité avec la communauté</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-6 h-6 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 max-h-[80vh] overflow-y-auto">
|
||||
{/* Warning */}
|
||||
<div className="flex gap-4 p-4 bg-amber-50 border border-amber-200 rounded-2xl">
|
||||
<div className="shrink-0 p-2 bg-amber-100 rounded-xl text-amber-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-amber-800">Information importante</h4>
|
||||
<p className="text-xs text-amber-700 leading-relaxed">
|
||||
Votre événement sera soumis à la validation de notre équipe de modération avant d'être publié sur la plateforme (généralement sous 24h).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Titre de l'événement *</label>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
placeholder="Ex: Conférence sur l'Agrotech"
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Date *</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
name="date"
|
||||
type="date"
|
||||
required
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Lieu *</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
name="location"
|
||||
required
|
||||
placeholder="Ex: Abidjan, Côte d'Ivoire"
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Description *</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Présentez votre événement en quelques lignes..."
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Affiche / Photo de l'événement</label>
|
||||
<div
|
||||
className={`relative border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-8 transition-all ${
|
||||
imagePreview ? 'border-brand-500 bg-brand-50' : 'border-gray-200 hover:border-brand-300 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{imagePreview ? (
|
||||
<div className="relative w-full aspect-video rounded-lg overflow-hidden shadow-inner">
|
||||
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImagePreview(null)}
|
||||
className="absolute top-2 right-2 p-1 bg-white/80 rounded-full hover:bg-white text-gray-600 shadow-lg"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
|
||||
)}
|
||||
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PNG, JPG jusqu'à 2Mo</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Lien externe (facultatif)</label>
|
||||
<div className="relative">
|
||||
<LinkIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
name="link"
|
||||
placeholder="https://votre-billetterie.com"
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-4 rounded-2xl font-bold text-gray-600 hover:bg-gray-100 transition-all"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || uploading}
|
||||
className="flex-[2] bg-brand-600 hover:bg-brand-700 disabled:bg-gray-200 disabled:cursor-not-allowed text-white px-6 py-4 rounded-2xl font-bold shadow-xl shadow-brand-200 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Soumission...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Calendar className="w-5 h-5" />
|
||||
Soumettre l'événement
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
components/dashboard/CreateNewsModal.tsx
Normal file
216
components/dashboard/CreateNewsModal.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useTransition } from 'react';
|
||||
import { X, Newspaper, User, Image as ImageIcon, AlertTriangle, Loader2, AlignLeft } from 'lucide-react';
|
||||
import { createNews } from '@/app/actions/news';
|
||||
import { uploadImage } from '@/app/actions/upload';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface CreateNewsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CreateNewsModal({ isOpen, onClose }: CreateNewsModalProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
const data = {
|
||||
title: formData.get('title') as string,
|
||||
excerpt: formData.get('excerpt') as string,
|
||||
content: formData.get('content') as string,
|
||||
author: formData.get('author') as string,
|
||||
imageUrl: imagePreview || "https://picsum.photos/800/400?random=news",
|
||||
};
|
||||
|
||||
if (!data.title || !data.excerpt || !data.content || !data.author) {
|
||||
toast.error("Veuillez remplir tous les champs obligatoires");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createNews(data);
|
||||
if (result.success) {
|
||||
toast.success("Actualité soumise avec succès !");
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(result.error || "Une erreur est survenue");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const result = await uploadImage(formData);
|
||||
if (result.success && result.url) {
|
||||
setImagePreview(result.url);
|
||||
} else {
|
||||
toast.error(result.error || "Erreur lors de l'upload");
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-indigo-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-indigo-100 rounded-xl text-indigo-600">
|
||||
<Newspaper className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Publier une actualité</h2>
|
||||
<p className="text-xs text-gray-500">Mettez en avant vos succès et nouveautés</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-6 h-6 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 max-h-[80vh] overflow-y-auto">
|
||||
{/* Warning */}
|
||||
<div className="flex gap-4 p-4 bg-amber-50 border border-amber-200 rounded-2xl">
|
||||
<div className="shrink-0 p-2 bg-amber-100 rounded-xl text-amber-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-amber-800">Information importante</h4>
|
||||
<p className="text-xs text-amber-700 leading-relaxed">
|
||||
Votre article sera soumis à la validation de notre équipe de modération avant d'être publié dans la section Actualités.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Titre de l'article *</label>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
placeholder="Ex: Lancement de notre nouvelle gamme de produits"
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Auteur *</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
name="author"
|
||||
required
|
||||
placeholder="Votre nom ou nom d'entreprise"
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Résumé court (Excerpt) *</label>
|
||||
<div className="relative">
|
||||
<AlignLeft className="absolute left-4 top-4 w-4 h-4 text-gray-400" />
|
||||
<textarea
|
||||
name="excerpt"
|
||||
required
|
||||
rows={2}
|
||||
placeholder="Un court résumé qui apparaîtra dans la liste des articles..."
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Contenu de l'article *</label>
|
||||
<textarea
|
||||
name="content"
|
||||
required
|
||||
rows={6}
|
||||
placeholder="Racontez votre histoire..."
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Image de couverture</label>
|
||||
<div
|
||||
className={`relative border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-8 transition-all ${
|
||||
imagePreview ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-indigo-300 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{imagePreview ? (
|
||||
<div className="relative w-full aspect-video rounded-lg overflow-hidden shadow-inner">
|
||||
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImagePreview(null)}
|
||||
className="absolute top-2 right-2 p-1 bg-white/80 rounded-full hover:bg-white text-gray-600 shadow-lg"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="w-10 h-10 text-gray-300 mb-2" />
|
||||
)}
|
||||
<p className="text-sm font-medium text-gray-500">Cliquez pour ajouter une image</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PNG, JPG jusqu'à 2Mo</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-4 rounded-2xl font-bold text-gray-600 hover:bg-gray-100 transition-all"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || uploading}
|
||||
className="flex-[2] bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-200 disabled:cursor-not-allowed text-white px-6 py-4 rounded-2xl font-bold shadow-xl shadow-indigo-200 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Soumission...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Newspaper className="w-5 h-5" />
|
||||
Soumettre l'actualité
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user