feat: implement manageable homepage banner slider with support for businesses and custom slides
This commit is contained in:
112
admin/src/app/actions/slides.ts
Normal file
112
admin/src/app/actions/slides.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
admin/src/app/slides/edit/[id]/page.tsx
Normal file
16
admin/src/app/slides/edit/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
5
admin/src/app/slides/new/page.tsx
Normal file
5
admin/src/app/slides/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import SlideForm from "@/components/SlideForm";
|
||||||
|
|
||||||
|
export default function NewSlidePage() {
|
||||||
|
return <SlideForm />;
|
||||||
|
}
|
||||||
115
admin/src/app/slides/page.tsx
Normal file
115
admin/src/app/slides/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Plus, Image as ImageIcon, Trash2, Edit, ExternalLink, Power, PowerOff } from 'lucide-react';
|
||||||
|
import DeleteSlideButton from '@/components/DeleteSlideButton';
|
||||||
|
import ToggleSlideStatusButton from '@/components/ToggleSlideStatusButton';
|
||||||
|
|
||||||
|
async function getData() {
|
||||||
|
try {
|
||||||
|
return await prisma.homeSlide.findMany({
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: { business: true }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch slides:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SlidesPage() {
|
||||||
|
const slides = await getData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-end mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Gestion de la Bannière</h1>
|
||||||
|
<p className="text-slate-400">Gérez les slides qui s'affichent sur la page d'accueil (Slider).</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/slides/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nouvelle Slide
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<ImageIcon className="text-indigo-400 w-5 h-5" />
|
||||||
|
<h2 className="text-xl font-bold text-white">Slides Actuelles ({slides.length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slides.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-slate-800/20 rounded-xl border border-dashed border-slate-700">
|
||||||
|
<p className="text-slate-500">Aucune slide configurée. Le bandeau par défaut sera affiché.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-3 px-4">Ordre</th>
|
||||||
|
<th className="text-left py-3 px-4">Type</th>
|
||||||
|
<th className="text-left py-3 px-4">Contenu</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{slides.map((slide) => (
|
||||||
|
<tr key={slide.id} className="hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<span className="text-white font-mono bg-slate-800 px-2 py-1 rounded">#{slide.order}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${slide.type === 'BUSINESS' ? 'bg-indigo-500/10 text-indigo-400 border border-indigo-500/20' : 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'}`}>
|
||||||
|
{slide.type === 'BUSINESS' ? 'ENTREPRISE' : 'PERSONNALISÉ'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{slide.type === 'BUSINESS' ? (
|
||||||
|
<>
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
|
||||||
|
<img src={slide.business?.logoUrl || '/placeholder-biz.png'} className="w-full h-full object-cover" alt="" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">{slide.business?.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{slide.business?.location}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-slate-800 overflow-hidden flex-shrink-0 border border-slate-700">
|
||||||
|
<img src={slide.imageUrl || '/placeholder-img.png'} className="w-full h-full object-cover" alt="" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">{slide.title || 'Sans titre'}</div>
|
||||||
|
<div className="text-xs text-slate-500 truncate max-w-[250px]">{slide.linkUrl || 'Pas de lien'}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<ToggleSlideStatusButton id={slide.id} currentStatus={slide.isActive} />
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link href={`/slides/edit/${slide.id}`} className="p-2 text-slate-400 hover:text-indigo-400 transition-colors">
|
||||||
|
<Edit className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<DeleteSlideButton id={slide.id} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
admin/src/components/DeleteSlideButton.tsx
Normal file
34
admin/src/components/DeleteSlideButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { deleteSlide } from '@/app/actions/slides';
|
||||||
|
import { Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function DeleteSlideButton({ id }: { id: string }) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm("Supprimer cette slide du slider d'accueil ?")) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteSlide(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Slide supprimée");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la suppression");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-2 text-slate-500 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||||
|
title="Supprimer la slide"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
FileText,
|
FileText,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Tags
|
Tags,
|
||||||
|
Image as ImageIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
@@ -29,6 +30,7 @@ const menuItems = [
|
|||||||
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
|
{ name: 'Tarifs & Plans', 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: 'Documents Légaux', icon: FileText, href: '/legal' },
|
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
||||||
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
||||||
];
|
];
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
admin/src/components/ToggleSlideStatusButton.tsx
Normal file
42
admin/src/components/ToggleSlideStatusButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { toggleSlideStatus } from '@/app/actions/slides';
|
||||||
|
import { Power, PowerOff, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function ToggleSlideStatusButton({ id, currentStatus }: { id: string, currentStatus: boolean }) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await toggleSlideStatus(id, currentStatus);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(currentStatus ? "Slide désactivée" : "Slide activée");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la mise à jour");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={isPending}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
currentStatus
|
||||||
|
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/20'
|
||||||
|
: 'bg-slate-500/10 text-slate-400 border border-slate-500/20 hover:bg-slate-500/20'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : currentStatus ? (
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<PowerOff className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{currentStatus ? 'ACTIVE' : 'INACTIVE'}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,14 +8,16 @@ import { generateSlug } from '@/lib/utils';
|
|||||||
import { Business, BlogPost } from '@/types';
|
import { Business, BlogPost } from '@/types';
|
||||||
import BusinessCard from '@/components/BusinessCard';
|
import BusinessCard from '@/components/BusinessCard';
|
||||||
import { useUser } from '@/components/UserProvider';
|
import { useUser } from '@/components/UserProvider';
|
||||||
|
import HomeSlider from '@/components/HomeSlider';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialFeatured: Business[];
|
initialFeatured: Business[];
|
||||||
initialPosts: BlogPost[];
|
initialPosts: BlogPost[];
|
||||||
initialCategories: any[];
|
initialCategories: any[];
|
||||||
|
initialSlides: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props) => {
|
const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialSlides }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, settings } = useUser();
|
const { user, settings } = useUser();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -23,11 +25,11 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
|||||||
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
|
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
|
||||||
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
||||||
const [categories, setCategories] = useState<any[]>(initialCategories);
|
const [categories, setCategories] = useState<any[]>(initialCategories);
|
||||||
|
const [slides, setSlides] = useState<any[]>(initialSlides);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingPosts, setLoadingPosts] = useState(false);
|
const [loadingPosts, setLoadingPosts] = useState(false);
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (searchTerm: string, locationTerm: string) => {
|
||||||
e.preventDefault();
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (searchTerm) params.append('q', searchTerm);
|
if (searchTerm) params.append('q', searchTerm);
|
||||||
if (locationTerm) params.append('location', locationTerm);
|
if (locationTerm) params.append('location', locationTerm);
|
||||||
@@ -36,47 +38,8 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Hero Section */}
|
{/* Dynamic Hero Slider */}
|
||||||
<div className="relative bg-dark-900 overflow-hidden">
|
<HomeSlider slides={slides} onSearch={handleSearch} />
|
||||||
<div className="absolute inset-0 opacity-40">
|
|
||||||
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" width={1740} height={1160} />
|
|
||||||
</div>
|
|
||||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
|
|
||||||
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
|
|
||||||
Boostez votre visibilité dans <br />
|
|
||||||
<span className="text-brand-500">l'écosystème africain</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
|
|
||||||
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="max-w-3xl bg-white p-2 rounded-lg shadow-xl flex flex-col md:flex-row gap-2">
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<Search className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Que recherchez-vous ? (ex: Développeur, Traiteur...)"
|
|
||||||
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
|
|
||||||
<MapPin className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Localisation (ex: Abidjan)"
|
|
||||||
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
|
|
||||||
value={locationTerm}
|
|
||||||
onChange={(e) => setLocationTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
|
|
||||||
Rechercher
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Featured Categories */}
|
{/* Featured Categories */}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
// Fetch initial data for faster loading and SEO
|
// Fetch initial data for faster loading and SEO
|
||||||
const [featuredBusinesses, posts, categories] = await Promise.all([
|
const [featuredBusinesses, posts, categories, slides] = await Promise.all([
|
||||||
prisma.business.findMany({
|
prisma.business.findMany({
|
||||||
where: { isFeatured: true, isActive: true, isSuspended: false },
|
where: { isFeatured: true, isActive: true, isSuspended: false },
|
||||||
take: 4,
|
take: 4,
|
||||||
@@ -58,6 +58,11 @@ export default async function HomePage() {
|
|||||||
prisma.businessCategory.findMany({
|
prisma.businessCategory.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
take: 8
|
take: 8
|
||||||
|
}),
|
||||||
|
prisma.homeSlide.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: { business: true }
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -65,12 +70,14 @@ export default async function HomePage() {
|
|||||||
const initialFeatured = JSON.parse(JSON.stringify(featuredBusinesses));
|
const initialFeatured = JSON.parse(JSON.stringify(featuredBusinesses));
|
||||||
const initialPosts = JSON.parse(JSON.stringify(posts));
|
const initialPosts = JSON.parse(JSON.stringify(posts));
|
||||||
const initialCategories = JSON.parse(JSON.stringify(categories));
|
const initialCategories = JSON.parse(JSON.stringify(categories));
|
||||||
|
const initialSlides = JSON.parse(JSON.stringify(slides));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeClient
|
<HomeClient
|
||||||
initialFeatured={initialFeatured}
|
initialFeatured={initialFeatured}
|
||||||
initialPosts={initialPosts}
|
initialPosts={initialPosts}
|
||||||
initialCategories={initialCategories}
|
initialCategories={initialCategories}
|
||||||
|
initialSlides={initialSlides}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
213
components/HomeSlider.tsx
Normal file
213
components/HomeSlider.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Search, MapPin, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface HomeSlide {
|
||||||
|
id: string;
|
||||||
|
type: 'BUSINESS' | 'CUSTOM';
|
||||||
|
businessId?: string | null;
|
||||||
|
business?: any;
|
||||||
|
title?: string | null;
|
||||||
|
subtitle?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
linkUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
slides: HomeSlide[];
|
||||||
|
onSearch: (searchTerm: string, locationTerm: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomeSlider = ({ slides, onSearch }: Props) => {
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [locationTerm, setLocationTerm] = useState('');
|
||||||
|
|
||||||
|
// Auto-slide every 6 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (slides.length <= 1) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrent((prev) => (prev === slides.length - 1 ? 0 : prev + 1));
|
||||||
|
}, 6000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [slides.length]);
|
||||||
|
|
||||||
|
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearch(searchTerm, locationTerm);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If no slides, show default hero
|
||||||
|
if (slides.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="relative bg-dark-900 overflow-hidden min-h-[600px] flex items-center">
|
||||||
|
<div className="absolute inset-0 opacity-40">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
alt="Default Banner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32 w-full">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight animate-in fade-in slide-in-from-left-4 duration-700">
|
||||||
|
Boostez votre visibilité dans <br />
|
||||||
|
<span className="text-brand-500">l'écosystème africain</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-300 mb-8 max-w-2xl animate-in fade-in slide-in-from-left-4 duration-700 delay-150">
|
||||||
|
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
|
||||||
|
</p>
|
||||||
|
<SearchForm
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
setSearchTerm={setSearchTerm}
|
||||||
|
locationTerm={locationTerm}
|
||||||
|
setLocationTerm={setLocationTerm}
|
||||||
|
onSubmit={handleSearchSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-[650px] md:h-[700px] overflow-hidden bg-black">
|
||||||
|
{slides.map((slide, index) => (
|
||||||
|
<div
|
||||||
|
key={slide.id}
|
||||||
|
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
||||||
|
index === current ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Background Image */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<img
|
||||||
|
src={slide.type === 'BUSINESS' ? (slide.business?.coverUrl || slide.business?.logoUrl) : (slide.imageUrl || '')}
|
||||||
|
className={`w-full h-full object-cover transition-transform duration-[6000ms] ease-linear ${
|
||||||
|
index === current ? 'scale-110' : 'scale-100'
|
||||||
|
}`}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/40 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative h-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center">
|
||||||
|
<div className={`max-w-3xl transition-all duration-700 delay-300 ${
|
||||||
|
index === current ? 'translate-x-0 opacity-100' : '-translate-x-10 opacity-0'
|
||||||
|
}`}>
|
||||||
|
{slide.type === 'BUSINESS' && (
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 bg-brand-600/20 border border-brand-500/30 rounded-full mb-6">
|
||||||
|
<span className="w-2 h-2 bg-brand-500 rounded-full animate-pulse" />
|
||||||
|
<span className="text-brand-400 text-xs font-bold uppercase tracking-widest">Entreprise à la une</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight leading-tight">
|
||||||
|
{slide.type === 'BUSINESS' ? (
|
||||||
|
<>Découvrez <span className="text-brand-500">{slide.business?.name}</span></>
|
||||||
|
) : (
|
||||||
|
slide.title
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-300 mb-8 max-w-2xl line-clamp-3">
|
||||||
|
{slide.type === 'BUSINESS' ? (
|
||||||
|
slide.business?.description
|
||||||
|
) : (
|
||||||
|
slide.subtitle
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 items-center">
|
||||||
|
<Link
|
||||||
|
href={slide.type === 'BUSINESS' ? `/annuaire/${slide.business?.id}` : (slide.linkUrl || '#')}
|
||||||
|
className="inline-flex items-center gap-2 bg-brand-600 text-white px-8 py-4 rounded-lg font-bold hover:bg-brand-700 transition-all shadow-lg shadow-brand-600/20 hover:scale-105"
|
||||||
|
>
|
||||||
|
{slide.type === 'BUSINESS' ? 'Voir la fiche' : 'En savoir plus'}
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Static Search Overlay (Always visible on top of slides) */}
|
||||||
|
<div className="absolute bottom-12 md:bottom-20 left-0 right-0 z-20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<SearchForm
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
setSearchTerm={setSearchTerm}
|
||||||
|
locationTerm={locationTerm}
|
||||||
|
setLocationTerm={setLocationTerm}
|
||||||
|
onSubmit={handleSearchSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Arrows */}
|
||||||
|
{slides.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrent(current === 0 ? slides.length - 1 : current - 1)}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 z-30 p-3 rounded-full bg-white/10 text-white hover:bg-white/20 transition-all backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrent(current === slides.length - 1 ? 0 : current + 1)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 z-30 p-3 rounded-full bg-white/10 text-white hover:bg-white/20 transition-all backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Indicators */}
|
||||||
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-30 flex gap-2">
|
||||||
|
{slides.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setCurrent(i)}
|
||||||
|
className={`w-2.5 h-2.5 rounded-full transition-all ${
|
||||||
|
i === current ? 'bg-brand-500 w-8' : 'bg-white/40'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SearchForm = ({ searchTerm, setSearchTerm, locationTerm, setLocationTerm, onSubmit }: any) => (
|
||||||
|
<form onSubmit={onSubmit} className="max-w-4xl bg-white/95 backdrop-blur-md p-2 rounded-xl shadow-2xl flex flex-col md:flex-row gap-2 border border-white/20 animate-in fade-in slide-in-from-bottom-4 duration-700 delay-500">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-3.5 text-gray-400 w-5 h-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Que recherchez-vous ? (ex: Traiteur, Photographe...)"
|
||||||
|
className="w-full pl-10 pr-4 py-3.5 rounded-lg focus:outline-none text-gray-900 font-medium"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-100">
|
||||||
|
<MapPin className="absolute left-3 top-3.5 text-gray-400 w-5 h-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Où ? (ex: Dakar)"
|
||||||
|
className="w-full pl-10 pr-4 py-3.5 rounded-lg focus:outline-none text-gray-900 font-medium"
|
||||||
|
value={locationTerm}
|
||||||
|
onChange={(e) => setLocationTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="bg-brand-600 text-white px-10 py-3.5 rounded-lg font-bold hover:bg-brand-700 transition-all hover:scale-105 active:scale-95 shadow-lg shadow-brand-600/30">
|
||||||
|
Rechercher
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default HomeSlider;
|
||||||
@@ -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
|
||||||
@@ -332,12 +348,17 @@ 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
|
||||||
PUBLISHED
|
PUBLISHED
|
||||||
|
|||||||
Reference in New Issue
Block a user