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é.
+
+ ) : (
+
+
+
+
+ | Ordre |
+ Type |
+ Contenu |
+ Statut |
+ Actions |
+
+
+
+ {slides.map((slide) => (
+
+ |
+ #{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'}
+
+
+
+
+
+
+ );
+}
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 */}
-
-
-

-
-
-
- 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.
-
-
-
-
-
+ {/* 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 (
+
+
+

+
+
+
+
+ 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) => (
+
+);
+
+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