feat: implement manageable homepage banner slider with support for businesses and custom slides
This commit is contained in:
287
admin/src/components/SlideForm.tsx
Normal file
287
admin/src/components/SlideForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user