feat: implement manageable homepage banner slider with support for businesses and custom slides

This commit is contained in:
2026-05-10 16:30:19 +02:00
parent 13b1730f6f
commit 87b13dce13
12 changed files with 868 additions and 51 deletions

View File

@@ -0,0 +1,112 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function getSlides() {
try {
return await prisma.homeSlide.findMany({
orderBy: { order: 'asc' },
include: { business: true }
});
} catch (error) {
console.error("Failed to get slides:", error);
return [];
}
}
export async function createSlide(data: any) {
try {
await prisma.homeSlide.create({
data: {
type: data.type,
businessId: data.businessId || null,
title: data.title || null,
subtitle: data.subtitle || null,
imageUrl: data.imageUrl || null,
linkUrl: data.linkUrl || null,
order: data.order || 0,
isActive: data.isActive ?? true,
}
});
revalidatePath("/slides");
return { success: true };
} catch (error) {
console.error("Failed to create slide:", error);
return { success: false, error: "Erreur lors de la création" };
}
}
export async function updateSlide(id: string, data: any) {
try {
await prisma.homeSlide.update({
where: { id },
data: {
type: data.type,
businessId: data.businessId || null,
title: data.title || null,
subtitle: data.subtitle || null,
imageUrl: data.imageUrl || null,
linkUrl: data.linkUrl || null,
order: data.order || 0,
isActive: data.isActive ?? true,
}
});
revalidatePath("/slides");
return { success: true };
} catch (error) {
console.error("Failed to update slide:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteSlide(id: string) {
try {
await prisma.homeSlide.delete({
where: { id }
});
revalidatePath("/slides");
return { success: true };
} catch (error) {
console.error("Failed to delete slide:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}
export async function toggleSlideStatus(id: string, currentStatus: boolean) {
try {
await prisma.homeSlide.update({
where: { id },
data: { isActive: !currentStatus }
});
revalidatePath("/slides");
return { success: true };
} catch (error) {
console.error("Failed to toggle slide status:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function searchBusinesses(query: string) {
try {
return await prisma.business.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ location: { contains: query, mode: 'insensitive' } },
],
isActive: true,
},
take: 10,
select: {
id: true,
name: true,
logoUrl: true,
location: true,
}
});
} catch (error) {
console.error("Failed to search businesses:", error);
return [];
}
}

View File

@@ -0,0 +1,16 @@
import SlideForm from "@/components/SlideForm";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
export default async function EditSlidePage({ params }: { params: { id: string } }) {
const slide = await prisma.homeSlide.findUnique({
where: { id: params.id },
include: { business: true }
});
if (!slide) {
notFound();
}
return <SlideForm initialData={slide} />;
}

View File

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

View File

@@ -0,0 +1,115 @@
"use server";
import { prisma } from "@/lib/prisma";
import Link from 'next/link';
import { Plus, Image as ImageIcon, Trash2, Edit, ExternalLink, Power, PowerOff } from 'lucide-react';
import DeleteSlideButton from '@/components/DeleteSlideButton';
import ToggleSlideStatusButton from '@/components/ToggleSlideStatusButton';
async function getData() {
try {
return await prisma.homeSlide.findMany({
orderBy: { order: 'asc' },
include: { business: true }
});
} catch (error) {
console.error("Failed to fetch slides:", error);
return [];
}
}
export default async function SlidesPage() {
const slides = await getData();
return (
<div className="space-y-8">
<div className="flex justify-between items-end mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion de la Bannière</h1>
<p className="text-slate-400">Gérez les slides qui s'affichent sur la page d'accueil (Slider).</p>
</div>
<Link href="/slides/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
<Plus className="w-4 h-4" />
Nouvelle Slide
</Link>
</div>
<div className="card">
<div className="flex items-center gap-2 mb-6">
<ImageIcon className="text-indigo-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Slides Actuelles ({slides.length})</h2>
</div>
{slides.length === 0 ? (
<div className="text-center py-12 bg-slate-800/20 rounded-xl border border-dashed border-slate-700">
<p className="text-slate-500">Aucune slide configurée. Le bandeau par défaut sera affiché.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="data-table w-full">
<thead>
<tr>
<th className="text-left py-3 px-4">Ordre</th>
<th className="text-left py-3 px-4">Type</th>
<th className="text-left py-3 px-4">Contenu</th>
<th className="text-left py-3 px-4">Statut</th>
<th className="text-right py-3 px-4">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{slides.map((slide) => (
<tr key={slide.id} className="hover:bg-slate-800/30 transition-colors">
<td className="py-4 px-4">
<span className="text-white font-mono bg-slate-800 px-2 py-1 rounded">#{slide.order}</span>
</td>
<td className="py-4 px-4">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${slide.type === 'BUSINESS' ? 'bg-indigo-500/10 text-indigo-400 border border-indigo-500/20' : 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'}`}>
{slide.type === 'BUSINESS' ? 'ENTREPRISE' : 'PERSONNALISÉ'}
</span>
</td>
<td className="py-4 px-4">
<div className="flex items-center gap-3">
{slide.type === 'BUSINESS' ? (
<>
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
<img src={slide.business?.logoUrl || '/placeholder-biz.png'} className="w-full h-full object-cover" alt="" />
</div>
<div>
<div className="text-sm font-bold text-white">{slide.business?.name}</div>
<div className="text-xs text-slate-500">{slide.business?.location}</div>
</div>
</>
) : (
<>
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
<img src={slide.imageUrl || '/placeholder-img.png'} className="w-full h-full object-cover" alt="" />
</div>
<div>
<div className="text-sm font-bold text-white">{slide.title || 'Sans titre'}</div>
<div className="text-xs text-slate-500 truncate max-w-[250px]">{slide.linkUrl || 'Pas de lien'}</div>
</div>
</>
)}
</div>
</td>
<td className="py-4 px-4">
<ToggleSlideStatusButton id={slide.id} currentStatus={slide.isActive} />
</td>
<td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2">
<Link href={`/slides/edit/${slide.id}`} className="p-2 text-slate-400 hover:text-indigo-400 transition-colors">
<Edit className="w-5 h-5" />
</Link>
<DeleteSlideButton id={slide.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useTransition } from 'react';
import { deleteSlide } from '@/app/actions/slides';
import { Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function DeleteSlideButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Supprimer cette slide du slider d'accueil ?")) {
startTransition(async () => {
const result = await deleteSlide(id);
if (result.success) {
toast.success("Slide supprimée");
} else {
toast.error(result.error || "Erreur lors de la suppression");
}
});
}
};
return (
<button
onClick={handleDelete}
disabled={isPending}
className="p-2 text-slate-500 hover:text-red-400 transition-colors disabled:opacity-50"
title="Supprimer la slide"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
);
}

View File

@@ -16,7 +16,8 @@ import {
Globe,
FileText,
Briefcase,
Tags
Tags,
Image as ImageIcon
} from 'lucide-react';
const menuItems = [
@@ -29,6 +30,7 @@ const menuItems = [
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
{ name: 'Pays', icon: Globe, href: '/countries' },
{ name: 'Bannière', icon: ImageIcon, href: '/slides' },
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
{ name: 'Configuration', icon: Settings, href: '/settings' },
];

View File

@@ -0,0 +1,287 @@
"use client";
import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createSlide, updateSlide, searchBusinesses } from '@/app/actions/slides';
import { Loader2, ArrowLeft, Save, Search, Check, X, ImageIcon, Briefcase } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import ImageUploader from './ImageUploader';
interface Props {
initialData?: any;
}
export default function SlideForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
// Form state
const [type, setType] = useState(initialData?.type || 'CUSTOM');
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
const [isActive, setIsActive] = useState(initialData?.isActive ?? true);
// Business search state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [selectedBusiness, setSelectedBusiness] = useState<any>(initialData?.business || null);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
if (searchQuery.length > 1) {
const delayDebounceFn = setTimeout(() => {
setIsSearching(true);
searchBusinesses(searchQuery).then(results => {
setSearchResults(results);
setIsSearching(false);
});
}, 300);
return () => clearTimeout(delayDebounceFn);
} else {
setSearchResults([]);
}
}, [searchQuery]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = {
type,
businessId: type === 'BUSINESS' ? selectedBusiness?.id : null,
title: formData.get('title') as string,
subtitle: formData.get('subtitle') as string,
imageUrl: type === 'CUSTOM' ? imageUrl : (selectedBusiness?.logoUrl || ''),
linkUrl: type === 'CUSTOM' ? formData.get('linkUrl') as string : `/annuaire/${selectedBusiness?.id}`,
order: parseInt(formData.get('order') as string) || 0,
isActive,
};
if (type === 'BUSINESS' && !data.businessId) {
toast.error("Veuillez sélectionner une entreprise");
return;
}
if (type === 'CUSTOM' && !data.imageUrl) {
toast.error("Une image est requise pour une slide personnalisée");
return;
}
startTransition(async () => {
const result = initialData
? await updateSlide(initialData.id, data)
: await createSlide(data);
if (result.success) {
toast.success(initialData ? "Slide mise à jour" : "Slide créée avec succès");
router.push('/slides');
router.refresh();
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
return (
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/slides" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">
{initialData ? 'Modifier la Slide' : 'Nouvelle Slide'}
</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6">
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Type de Slide</h2>
<div className="flex bg-slate-900 p-1 rounded-lg border border-slate-800">
<button
type="button"
onClick={() => setType('BUSINESS')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all ${
type === 'BUSINESS' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'
}`}
>
<Briefcase className="w-4 h-4" />
Entreprise
</button>
<button
type="button"
onClick={() => setType('CUSTOM')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all ${
type === 'CUSTOM' ? 'bg-emerald-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'
}`}
>
<ImageIcon className="w-4 h-4" />
Personnalisé
</button>
</div>
</div>
{type === 'BUSINESS' ? (
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
<label className="text-sm font-medium text-slate-400">Rechercher une entreprise</label>
{selectedBusiness ? (
<div className="flex items-center justify-between p-4 bg-indigo-500/10 border border-indigo-500/20 rounded-xl">
<div className="flex items-center gap-4">
<img src={selectedBusiness.logoUrl} className="w-12 h-12 rounded-lg object-cover" alt="" />
<div>
<div className="text-white font-bold">{selectedBusiness.name}</div>
<div className="text-xs text-indigo-400">{selectedBusiness.location}</div>
</div>
</div>
<button
type="button"
onClick={() => setSelectedBusiness(null)}
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
) : (
<div className="relative">
<Search className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Tapez le nom d'une entreprise..."
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
{isSearching && (
<div className="absolute right-3 top-3">
<Loader2 className="w-5 h-5 text-indigo-500 animate-spin" />
</div>
)}
{searchResults.length > 0 && (
<div className="absolute z-10 w-full mt-2 bg-slate-800 border border-slate-700 rounded-xl shadow-2xl overflow-hidden max-h-60 overflow-y-auto">
{searchResults.map((biz) => (
<button
key={biz.id}
type="button"
onClick={() => {
setSelectedBusiness(biz);
setSearchQuery('');
setSearchResults([]);
}}
className="w-full flex items-center gap-3 p-3 hover:bg-slate-700 text-left transition-colors border-b border-slate-700 last:border-0"
>
<img src={biz.logoUrl} className="w-10 h-10 rounded object-cover" alt="" />
<div>
<div className="text-sm font-bold text-white">{biz.name}</div>
<div className="text-xs text-slate-400">{biz.location}</div>
</div>
</button>
))}
</div>
)}
</div>
)}
<div className="p-4 bg-slate-900/50 rounded-lg border border-slate-800 italic text-xs text-slate-500">
L'image, le titre et le lien seront automatiquement récupérés depuis la fiche de l'entreprise.
</div>
</div>
) : (
<div className="space-y-6 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre</label>
<input
name="title"
defaultValue={initialData?.title}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
placeholder="Ex: Découvrez l'artisanat local"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
<input
name="subtitle"
defaultValue={initialData?.subtitle}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
placeholder="Ex: Des produits uniques faits main"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Lien (URL)</label>
<input
name="linkUrl"
defaultValue={initialData?.linkUrl}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-emerald-500"
placeholder="Ex: /annuaire ou https://..."
/>
</div>
<ImageUploader
label="Image de fond"
value={imageUrl}
onChange={setImageUrl}
name="imageUrl"
/>
</div>
)}
</div>
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Paramètres d'Affichage</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Ordre d'affichage</label>
<input
name="order"
type="number"
defaultValue={initialData?.order || 0}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="0"
/>
<p className="text-xs text-slate-500">Les slides sont triées par ordre croissant.</p>
</div>
<div className="flex items-center justify-between p-4 bg-slate-900 rounded-xl border border-slate-800">
<span className="text-sm font-medium text-slate-300">Statut de la slide</span>
<button
type="button"
onClick={() => setIsActive(!isActive)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
isActive ? 'bg-emerald-600' : 'bg-slate-700'
}`}
>
<span
className={`${
isActive ? 'translate-x-6' : 'translate-x-1'
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
/>
</button>
</div>
</div>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Créer la slide'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { useTransition } from 'react';
import { toggleSlideStatus } from '@/app/actions/slides';
import { Power, PowerOff, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function ToggleSlideStatusButton({ id, currentStatus }: { id: string, currentStatus: boolean }) {
const [isPending, startTransition] = useTransition();
const handleToggle = () => {
startTransition(async () => {
const result = await toggleSlideStatus(id, currentStatus);
if (result.success) {
toast.success(currentStatus ? "Slide désactivée" : "Slide activée");
} else {
toast.error(result.error || "Erreur lors de la mise à jour");
}
});
};
return (
<button
onClick={handleToggle}
disabled={isPending}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
currentStatus
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/20'
: 'bg-slate-500/10 text-slate-400 border border-slate-500/20 hover:bg-slate-500/20'
} disabled:opacity-50`}
>
{isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : currentStatus ? (
<Power className="w-3.5 h-3.5" />
) : (
<PowerOff className="w-3.5 h-3.5" />
)}
<span>{currentStatus ? 'ACTIVE' : 'INACTIVE'}</span>
</button>
);
}