From 87b13dce13eb8df773743d7dfb073eebc9046071 Mon Sep 17 00:00:00 2001 From: streaper2 Date: Sun, 10 May 2026 16:30:19 +0200 Subject: [PATCH] feat: implement manageable homepage banner slider with support for businesses and custom slides --- admin/src/app/actions/slides.ts | 112 +++++++ admin/src/app/slides/edit/[id]/page.tsx | 16 + admin/src/app/slides/new/page.tsx | 5 + admin/src/app/slides/page.tsx | 115 +++++++ admin/src/components/DeleteSlideButton.tsx | 34 +++ admin/src/components/Sidebar.tsx | 4 +- admin/src/components/SlideForm.tsx | 287 ++++++++++++++++++ .../components/ToggleSlideStatusButton.tsx | 42 +++ app/HomeClient.tsx | 51 +--- app/page.tsx | 9 +- components/HomeSlider.tsx | 213 +++++++++++++ prisma/schema.prisma | 31 +- 12 files changed, 868 insertions(+), 51 deletions(-) create mode 100644 admin/src/app/actions/slides.ts create mode 100644 admin/src/app/slides/edit/[id]/page.tsx create mode 100644 admin/src/app/slides/new/page.tsx create mode 100644 admin/src/app/slides/page.tsx create mode 100644 admin/src/components/DeleteSlideButton.tsx create mode 100644 admin/src/components/SlideForm.tsx create mode 100644 admin/src/components/ToggleSlideStatusButton.tsx create mode 100644 components/HomeSlider.tsx diff --git a/admin/src/app/actions/slides.ts b/admin/src/app/actions/slides.ts new file mode 100644 index 0000000..69e7922 --- /dev/null +++ b/admin/src/app/actions/slides.ts @@ -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 []; + } +} diff --git a/admin/src/app/slides/edit/[id]/page.tsx b/admin/src/app/slides/edit/[id]/page.tsx new file mode 100644 index 0000000..5855896 --- /dev/null +++ b/admin/src/app/slides/edit/[id]/page.tsx @@ -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 ; +} diff --git a/admin/src/app/slides/new/page.tsx b/admin/src/app/slides/new/page.tsx new file mode 100644 index 0000000..68e1f61 --- /dev/null +++ b/admin/src/app/slides/new/page.tsx @@ -0,0 +1,5 @@ +import SlideForm from "@/components/SlideForm"; + +export default function NewSlidePage() { + return ; +} diff --git a/admin/src/app/slides/page.tsx b/admin/src/app/slides/page.tsx new file mode 100644 index 0000000..8ad00c9 --- /dev/null +++ b/admin/src/app/slides/page.tsx @@ -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 ( +
+
+
+

Gestion de la Bannière

+

Gérez les slides qui s'affichent sur la page d'accueil (Slider).

+
+ + + Nouvelle Slide + +
+ +
+
+ +

Slides Actuelles ({slides.length})

+
+ + {slides.length === 0 ? ( +
+

Aucune slide configurée. Le bandeau par défaut sera affiché.

+
+ ) : ( +
+ + + + + + + + + + + + {slides.map((slide) => ( + + + + + + + + ))} + +
OrdreTypeContenuStatutActions
+ #{slide.order} + + + {slide.type === 'BUSINESS' ? 'ENTREPRISE' : 'PERSONNALISÉ'} + + +
+ {slide.type === 'BUSINESS' ? ( + <> +
+ +
+
+
{slide.business?.name}
+
{slide.business?.location}
+
+ + ) : ( + <> +
+ +
+
+
{slide.title || 'Sans titre'}
+
{slide.linkUrl || 'Pas de lien'}
+
+ + )} +
+
+ + +
+ + + + +
+
+
+ )} +
+
+ ); +} diff --git a/admin/src/components/DeleteSlideButton.tsx b/admin/src/components/DeleteSlideButton.tsx new file mode 100644 index 0000000..8c4e2a0 --- /dev/null +++ b/admin/src/components/DeleteSlideButton.tsx @@ -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 ( + + ); +} diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index 3c2688a..24d5f09 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -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' }, ]; diff --git a/admin/src/components/SlideForm.tsx b/admin/src/components/SlideForm.tsx new file mode 100644 index 0000000..101e385 --- /dev/null +++ b/admin/src/components/SlideForm.tsx @@ -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([]); + const [selectedBusiness, setSelectedBusiness] = useState(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) => { + 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 ( +
+
+
+ + + +

+ {initialData ? 'Modifier la Slide' : 'Nouvelle Slide'} +

+
+
+ +
+
+
+

Type de Slide

+
+ + +
+
+ + {type === 'BUSINESS' ? ( +
+ + + {selectedBusiness ? ( +
+
+ +
+
{selectedBusiness.name}
+
{selectedBusiness.location}
+
+
+ +
+ ) : ( +
+ + 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 && ( +
+ +
+ )} + + {searchResults.length > 0 && ( +
+ {searchResults.map((biz) => ( + + ))} +
+ )} +
+ )} + +
+ L'image, le titre et le lien seront automatiquement récupérés depuis la fiche de l'entreprise. +
+
+ ) : ( +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+ )} +
+ +
+

Paramètres d'Affichage

+ +
+
+ + +

Les slides sont triées par ordre croissant.

+
+
+ Statut de la slide + +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/admin/src/components/ToggleSlideStatusButton.tsx b/admin/src/components/ToggleSlideStatusButton.tsx new file mode 100644 index 0000000..a1042e9 --- /dev/null +++ b/admin/src/components/ToggleSlideStatusButton.tsx @@ -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 ( + + ); +} diff --git a/app/HomeClient.tsx b/app/HomeClient.tsx index 6ba4b83..1435d54 100644 --- a/app/HomeClient.tsx +++ b/app/HomeClient.tsx @@ -8,14 +8,16 @@ import { generateSlug } from '@/lib/utils'; import { Business, BlogPost } from '@/types'; import BusinessCard from '@/components/BusinessCard'; import { useUser } from '@/components/UserProvider'; +import HomeSlider from '@/components/HomeSlider'; interface Props { initialFeatured: Business[]; initialPosts: BlogPost[]; initialCategories: any[]; + initialSlides: any[]; } -const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props) => { +const HomeClient = ({ initialFeatured, initialPosts, initialCategories, initialSlides }: Props) => { const router = useRouter(); const { user, settings } = useUser(); const [searchTerm, setSearchTerm] = useState(''); @@ -23,11 +25,11 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props) const [featuredBusinesses, setFeaturedBusinesses] = useState(initialFeatured); const [posts, setPosts] = useState(initialPosts); const [categories, setCategories] = useState(initialCategories); + const [slides, setSlides] = useState(initialSlides); const [loading, setLoading] = useState(false); const [loadingPosts, setLoadingPosts] = useState(false); - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); + const handleSearch = (searchTerm: string, locationTerm: string) => { const params = new URLSearchParams(); if (searchTerm) params.append('q', searchTerm); if (locationTerm) params.append('location', locationTerm); @@ -36,47 +38,8 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props) return (
- {/* Hero Section */} -
-
- African entrepreneurs team -
-
-

- Boostez votre visibilité dans
- l'écosystème africain -

-

- L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent. -

- -
-
- - setSearchTerm(e.target.value)} - /> -
-
- - setLocationTerm(e.target.value)} - /> -
- -
-
-
+ {/* Dynamic Hero Slider */} + {/* Featured Categories */}
diff --git a/app/page.tsx b/app/page.tsx index b692934..f80f767 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -44,7 +44,7 @@ export async function generateMetadata(): Promise { export default async function HomePage() { // 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({ where: { isFeatured: true, isActive: true, isSuspended: false }, take: 4, @@ -58,6 +58,11 @@ export default async function HomePage() { prisma.businessCategory.findMany({ where: { isActive: true }, 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 initialPosts = JSON.parse(JSON.stringify(posts)); const initialCategories = JSON.parse(JSON.stringify(categories)); + const initialSlides = JSON.parse(JSON.stringify(slides)); return ( ); } diff --git a/components/HomeSlider.tsx b/components/HomeSlider.tsx new file mode 100644 index 0000000..99d8a9f --- /dev/null +++ b/components/HomeSlider.tsx @@ -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 ( +
+
+ Default Banner +
+
+
+

+ Boostez votre visibilité dans
+ l'écosystème africain +

+

+ L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent. +

+ +
+
+
+ ); + } + + return ( +
+ {slides.map((slide, index) => ( +
+ {/* Background Image */} +
+ +
+
+ + {/* Content */} +
+
+ {slide.type === 'BUSINESS' && ( +
+ + Entreprise à la une +
+ )} + +

+ {slide.type === 'BUSINESS' ? ( + <>Découvrez {slide.business?.name} + ) : ( + slide.title + )} +

+ +

+ {slide.type === 'BUSINESS' ? ( + slide.business?.description + ) : ( + slide.subtitle + )} +

+ +
+ + {slide.type === 'BUSINESS' ? 'Voir la fiche' : 'En savoir plus'} + + +
+
+
+
+ ))} + + {/* Static Search Overlay (Always visible on top of slides) */} +
+
+ +
+
+ + {/* Navigation Arrows */} + {slides.length > 1 && ( + <> + + + + {/* Indicators */} +
+ {slides.map((_, i) => ( +
+ + )} +
+ ); +}; + +const SearchForm = ({ searchTerm, setSearchTerm, locationTerm, setLocationTerm, onSubmit }: any) => ( +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ + setLocationTerm(e.target.value)} + /> +
+ +
+); + +export default HomeSlider; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ce93b4f..183f8b5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,11 +30,11 @@ model User { businesses Business? comments Comment[] conversations ConversationParticipant[] + favorites Favorite[] sentMessages Message[] reports MessageReport[] ratings Rating[] country Country? @relation(fields: [countryId], references: [id]) - favorites Favorite[] } model Business { @@ -68,8 +68,6 @@ model Business { websiteUrl String? isSuspended Boolean @default(false) suspensionReason String? - metaTitle String? - metaDescription String? plan Plan @default(STARTER) city String? countryId String? @@ -78,14 +76,17 @@ model Business { coverZoom Float? @default(1.0) suggestedCategory String? categoryId String? + metaDescription String? + metaTitle String? categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id]) country Country? @relation(fields: [countryId], references: [id]) owner User @relation(fields: [ownerId], references: [id]) comments Comment[] conversations Conversation[] + favorites Favorite[] + homeSlides HomeSlide[] offers Offer[] ratings Rating[] - favorites Favorite[] } model BusinessCategory { @@ -161,6 +162,21 @@ model BlogPost { 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 { id String @id @default(uuid()) title String @@ -332,12 +348,17 @@ model Favorite { userId String businessId String createdAt DateTime @default(now()) - user User @relation(fields: [userId], 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]) } +enum HomeSlideType { + BUSINESS + CUSTOM +} + enum ContentStatus { DRAFT PUBLISHED