migration correction et maj
Some checks failed
Build and Push App / build (push) Failing after 11m56s

This commit is contained in:
2026-05-03 23:15:14 +02:00
parent 5f421b418e
commit 16d66c0456
19 changed files with 1482 additions and 1355 deletions

160
app/HomeClient.tsx Normal file
View File

@@ -0,0 +1,160 @@
"use client";
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
import { generateSlug } from '@/lib/utils';
import { Business, BlogPost } from '@/types';
import BusinessCard from '@/components/BusinessCard';
import { useUser } from '@/components/UserProvider';
interface Props {
initialFeatured: Business[];
initialPosts: BlogPost[];
initialCategories: any[];
}
const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props) => {
const router = useRouter();
const { user, settings } = useUser();
const [searchTerm, setSearchTerm] = useState('');
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
const [categories, setCategories] = useState<any[]>(initialCategories);
const [loading, setLoading] = useState(false);
const [loadingPosts, setLoadingPosts] = useState(false);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
router.push(`/annuaire?q=${searchTerm}`);
};
return (
<div>
{/* Hero Section */}
<div className="relative bg-dark-900 overflow-hidden">
<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" />
</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 */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<h2 className="text-3xl font-bold text-gray-900 mb-10 font-serif text-center">Secteurs en vedette</h2>
<div className="flex flex-wrap justify-center gap-4 sm:gap-6">
{(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
<Link
key={idx}
href={`/annuaire?category=${encodeURIComponent(cat)}`}
className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center w-[calc(50%-0.5rem)] sm:w-48 lg:w-56 flex flex-col items-center"
>
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
<Briefcase className="w-6 h-6" />
</div>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base line-clamp-2">{cat}</h3>
</Link>
))}
</div>
</div>
{/* Featured Businesses */}
<div className="bg-gray-50 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">Entreprises à la une</h2>
<p className="text-gray-500 mt-2">Découvrez les pépites de notre communauté.</p>
</div>
<Link href="/annuaire" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{featuredBusinesses.map(biz => (
<BusinessCard key={biz.id} business={biz} />
))}
</div>
</div>
</div>
{/* Blog Section */}
{settings?.homeBlogShow !== false && (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
</div>
<Link href="/blog" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout le blog <ArrowRight className="w-4 h-4 ml-1" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts
.filter(post => {
if (settings?.homeBlogSelection === 'manual') {
return settings.homeBlogIds?.includes(post.id);
}
if (settings?.homeBlogSelection === 'category') {
return post.tags?.some((tag: string) => settings.homeBlogCategories?.includes(tag));
}
return true;
})
.slice(0, settings?.homeBlogCount || 3)
.map(post => (
<Link key={post.id} href={`/blog/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
<div className="h-48 overflow-hidden">
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
</div>
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-brand-600 transition-colors">{post.title}</h3>
<p className="text-gray-500 text-sm line-clamp-3 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between mt-auto pt-4 border-t border-gray-50">
<span className="text-xs font-medium text-gray-400">{new Date(post.date).toLocaleDateString('fr-FR')}</span>
<span className="text-brand-600 font-bold text-xs">Lire la suite </span>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default HomeClient;

View File

@@ -0,0 +1,469 @@
"use client";
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Twitter, Play, Share2, Send, Award, Quote, MessageCircle, Lock, Loader2, Link2, ShoppingBag, Heart } from 'lucide-react';
import { Business, OfferType, Rating } from '@/types';
import { toast } from 'react-hot-toast';
import { useUser } from '@/components/UserProvider';
interface Props {
initialBusiness: Business;
}
const BusinessDetailClient = ({ initialBusiness }: Props) => {
const { id } = useParams();
const router = useRouter();
const { user } = useUser();
const [business, setBusiness] = useState<Business>(initialBusiness);
const [loading, setLoading] = useState(false);
const [existingConversationId, setExistingConversationId] = useState<string | null>(null);
const [isShareOpen, setIsShareOpen] = useState(false);
const [selectedOffer, setSelectedOffer] = useState<any | null>(null);
const [userRating, setUserRating] = useState<number | null>(null);
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [isRating, setIsRating] = useState(false);
const [ratings, setRatings] = useState<Rating[]>([]);
const [reviewComment, setReviewComment] = useState('');
const [userRatingStatus, setUserRatingStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED' | null>(null);
const [loadingRatings, setLoadingRatings] = useState(false);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [isFavorited, setIsFavorited] = useState(false);
const [isTogglingFavorite, setIsTogglingFavorite] = useState(false);
const isOwner = user && business && user.id === business.ownerId;
const contactRef = React.useRef<HTMLDivElement>(null);
const [contactForm, setContactForm] = useState({
name: '',
email: '',
message: ''
});
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
const handleOrder = (offer: any) => {
setContactForm(prev => ({
...prev,
message: `Bonjour, je souhaiterais commander ou avoir plus d'informations sur votre offre : "${offer.title}". Pouvez-vous me recontacter ?`
}));
setSelectedOffer(null);
contactRef.current?.scrollIntoView({ behavior: 'smooth' });
setTimeout(() => {
const textarea = document.getElementById('contact-message');
if (textarea) textarea.focus();
}, 800);
toast.success("Message préparé ! Vous pouvez l'envoyer ci-dessous.");
};
const businessOffers = useMemo(() => {
if (!business) return [];
if (business.offers && business.offers.length > 0) return business.offers;
return [];
}, [business]);
useEffect(() => {
const loadExtras = async () => {
// Track view event if not owner
if (!user || user.id !== business.ownerId) {
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'BUSINESS_VIEW',
path: window.location.pathname,
label: business.name,
metadata: {
businessId: business.id,
businessName: business.name,
userId: user?.id || 'anonymous'
}
})
}).catch(console.error);
fetch(`/api/businesses/${business.id}/view`, {
method: 'POST',
headers: user ? { 'x-user-id': user.id } : {}
}).catch(console.error);
}
// Fetch user rating if logged in
if (user) {
try {
const ratingRes = await fetch(`/api/businesses/${business.id}/rating/me`, {
headers: { 'x-user-id': user.id }
});
const ratingData = await ratingRes.json();
if (ratingData.rating) {
setUserRating(ratingData.rating);
setReviewComment(ratingData.comment || '');
setUserRatingStatus(ratingData.status);
}
} catch (e) { console.error(e); }
// Fetch favorite status
try {
const favRes = await fetch('/api/favorites', {
headers: { 'x-user-id': user.id }
});
const favData = await favRes.json();
if (Array.isArray(favData)) {
setIsFavorited(favData.some((f: any) => f.businessId === business.id));
}
} catch (e) { console.error(e); }
}
// Fetch all ratings
try {
setLoadingRatings(true);
const ratingsRes = await fetch(`/api/businesses/${business.id}/ratings`, {
headers: user ? { 'x-user-id': user.id } : {}
});
if (ratingsRes.ok) {
const ratingsData = await ratingsRes.json();
setRatings(ratingsData);
}
} catch (e) { console.error(e); } finally { setLoadingRatings(false); }
};
loadExtras();
}, [business.id, user]);
useEffect(() => {
const checkExistingConversation = async () => {
if (!user || user.id === 'undefined' || !business?.id) return;
try {
const res = await fetch(`/api/conversations?businessId=${business.id}`, {
headers: { 'x-user-id': user.id }
});
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
setExistingConversationId(data[0].id);
}
} catch (error) { console.error(error); }
};
if (user && business) checkExistingConversation();
}, [user, business]);
const getEmbedUrl = (url: string) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? `https://www.youtube.com/embed/${match[2]}` : null;
};
const videoEmbedUrl = business.videoUrl ? getEmbedUrl(business.videoUrl) : null;
const handleTrackEvent = async (type: string, label?: string) => {
if (!business?.id || (user && user.id === business.ownerId)) return;
try {
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
path: window.location.pathname,
label: label || business.name,
metadata: {
businessId: business.id,
businessName: business.name,
userId: user?.id || 'anonymous'
}
})
});
} catch (e) { console.error(e); }
};
const handleContactSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) {
toast.error('Veuillez vous connecter pour envoyer un message');
router.push('/login');
return;
}
setFormStatus('sending');
try {
const res = await fetch('/api/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user.id
},
body: JSON.stringify({
businessId: business.id,
content: contactForm.message
})
});
const data = await res.json();
if (!data.error) {
handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Message Sent');
setFormStatus('success');
toast.success('Message envoyé ! Redirection vers votre messagerie...');
setContactForm({ ...contactForm, message: '' });
setTimeout(() => router.push('/dashboard?view=messages'), 2000);
} else {
toast.error(data.error);
setFormStatus('idle');
}
} catch (error) {
toast.error("Erreur lors de l'envoi");
setFormStatus('idle');
}
};
const handleShare = (platform: string) => {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(`Découvrez ${business.name} sur Afrohub`);
let shareLink = '';
switch(platform) {
case 'facebook': shareLink = `https://www.facebook.com/sharer/sharer.php?u=${url}`; break;
case 'twitter': shareLink = `https://twitter.com/intent/tweet?url=${url}&text=${text}`; break;
case 'linkedin': shareLink = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`; break;
case 'copy':
navigator.clipboard.writeText(window.location.href);
toast.success("Lien copié !");
setIsShareOpen(false);
return;
}
if (shareLink) window.open(shareLink, '_blank', 'width=600,height=400');
setIsShareOpen(false);
};
const handleRate = async (value: number) => {
if (!user) { toast.error('Connectez-vous pour voter'); router.push('/login'); return; }
if (isOwner) { toast.error("Vous ne pouvez pas noter votre propre entreprise"); return; }
if (isRating) return;
setIsRating(true);
try {
const res = await fetch(`/api/businesses/${business.id}/rate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
body: JSON.stringify({ value, comment: reviewComment })
});
const data = await res.json();
if (!data.error) {
setUserRating(value);
setBusiness(prev => ({ ...prev, rating: data.rating, ratingCount: data.ratingCount }));
toast.success('Merci pour votre vote !');
handleTrackEvent('BUSINESS_RATING_CAST', `Stars: ${value}`);
const rRes = await fetch(`/api/businesses/${business.id}/ratings`);
if (rRes.ok) setRatings(await rRes.json());
} else toast.error(data.error);
} catch (error) { toast.error("Erreur vote"); } finally { setIsRating(false); }
};
const handleReply = async (ratingId: string) => {
if (!user || !isOwner || !replyText.trim()) return;
setIsSubmittingReply(true);
try {
const res = await fetch(`/api/ratings/${ratingId}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
body: JSON.stringify({ reply: replyText })
});
if (res.ok) {
toast.success("Réponse publiée !");
setReplyingTo(null);
setReplyText('');
const rRes = await fetch(`/api/businesses/${business.id}/ratings`);
if (rRes.ok) setRatings(await rRes.json());
} else toast.error("Erreur réponse");
} catch (e) { toast.error("Erreur connexion"); } finally { setIsSubmittingReply(false); }
};
const toggleFavorite = async () => {
if (!user) { toast.error("Connectez-vous"); router.push('/login'); return; }
setIsTogglingFavorite(true);
try {
const res = await fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
body: JSON.stringify({ businessId: business.id })
});
const data = await res.json();
if (!data.error) {
setIsFavorited(data.favorited);
toast.success(data.favorited ? 'Ajouté aux favoris' : 'Retiré des favoris');
}
} catch (error) { toast.error('Erreur'); } finally { setIsTogglingFavorite(false); }
};
return (
<div className="bg-gray-50 min-h-screen pb-12">
{/* Header Banner */}
<div className="h-48 md:h-64 bg-gray-900 relative overflow-hidden">
{business.coverUrl ? (
<img
src={business.coverUrl}
alt="Couverture"
className="absolute inset-0 w-full h-full object-cover opacity-60"
style={{
objectPosition: business.coverPosition || '50% 50%',
transform: `scale(${business.coverZoom || 1})`
}}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-r from-gray-900 to-gray-800 opacity-100">
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] pointer-events-none"></div>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex flex-col justify-between py-6 relative z-10">
<button
onClick={() => router.back()}
className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit group active:scale-95"
>
<ArrowLeft className="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" /> Retour
</button>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 relative z-10">
<div className="bg-white rounded-xl shadow-lg border border-gray-100">
<div className="p-6 md:p-8">
{/* Profile Header */}
<div className="flex flex-col md:flex-row gap-6 items-start">
<div className="w-32 h-32 md:w-40 md:h-40 rounded-xl bg-white p-1 shadow-md -mt-16 md:-mt-24 border border-gray-100 flex-shrink-0 relative z-20">
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-lg bg-gray-50" />
{business.isFeatured && (
<div className="absolute -top-3 -right-3 bg-yellow-400 text-yellow-900 rounded-full p-2 shadow-md" title="Afroshine">
<Award className="w-6 h-6" />
</div>
)}
</div>
<div className="flex-1 w-full">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-serif font-bold text-gray-900 flex items-center gap-2">
{business.name}
</h1>
{business.verified && <span title="Entreprise Vérifiée"><CheckCircle className="w-6 h-6 text-blue-500" /></span>}
</div>
<div className="text-brand-600 font-medium mt-1 uppercase tracking-wide text-sm">{business.category}</div>
</div>
<div className="flex gap-3 relative">
<div className="relative">
<button
onClick={() => setIsShareOpen(!isShareOpen)}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 transition-all active:scale-95"
>
<Share2 className="w-4 h-4 mr-2" /> Partager
</button>
{isShareOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-2xl py-3 border border-gray-100 z-50 animate-in fade-in zoom-in duration-200">
<button onClick={() => handleShare('facebook')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-600">
<Facebook className="w-4 h-4 mr-3" /> Facebook
</button>
<button onClick={() => handleShare('twitter')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-black">
<Twitter className="w-4 h-4 mr-3" /> X (Twitter)
</button>
<button onClick={() => handleShare('copy')} className="w-full text-left px-4 py-2.5 text-sm font-medium text-brand-600 hover:bg-brand-50 flex items-center">
<Link2 className="w-4 h-4 mr-3" /> Copier le lien
</button>
</div>
)}
</div>
<button
onClick={toggleFavorite}
disabled={isTogglingFavorite}
className={`inline-flex items-center px-4 py-2 border shadow-sm text-sm font-medium rounded-md transition-all active:scale-95 ${isFavorited ? 'bg-red-500 border-red-500 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
<Heart className={`w-4 h-4 mr-2 ${isFavorited ? 'fill-current' : ''}`} />
{isFavorited ? 'Favori' : 'Mettre en favori'}
</button>
<a href="#contact-form" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700">
Contacter
</a>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-6 text-sm text-gray-500 border-t border-gray-100 pt-4">
<div className="flex items-center">
<MapPin className="w-4 h-4 mr-1 text-gray-400" />
{business.location}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-5 h-5 ${star <= Math.round(business.rating) ? 'text-yellow-400 fill-current' : 'text-gray-300'}`}
/>
))}
</div>
<span className="font-bold text-gray-900">{business.rating.toFixed(1)}</span>
<span className="text-gray-400">({business.ratingCount} votes {business.viewCount} vues)</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8">
<div className="lg:col-span-2 space-y-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4">À propos</h2>
<p className="text-gray-600 leading-relaxed whitespace-pre-line">{business.description}</p>
</div>
{videoEmbedUrl && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4 flex items-center">
<Play className="w-5 h-5 mr-2 text-brand-600" /> Présentation Vidéo
</h2>
<iframe src={videoEmbedUrl} className="w-full h-64 md:h-96 rounded-lg" allowFullScreen></iframe>
</div>
)}
{businessOffers.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Produits & Services</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{businessOffers.map(offer => (
<div key={offer.id} className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-all cursor-pointer group" onClick={() => handleOrder(offer)}>
<div className="h-40 bg-gray-100 relative">
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover" />
</div>
<div className="p-4">
<h3 className="font-bold text-gray-900">{offer.title}</h3>
<p className="text-brand-600 font-bold mt-1">{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="space-y-8">
<div id="contact-form" ref={contactRef} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Contacter l'entreprise</h2>
<form onSubmit={handleContactSubmit} className="space-y-4">
<textarea
id="contact-message"
value={contactForm.message}
onChange={(e) => setContactForm({...contactForm, message: e.target.value})}
placeholder="Votre message..."
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm focus:ring-2 focus:ring-brand-500 outline-none h-32"
required
/>
<button type="submit" disabled={formStatus === 'sending'} className="w-full bg-brand-600 text-white py-3 rounded-lg font-bold hover:bg-brand-700 transition-colors flex items-center justify-center gap-2">
{formStatus === 'sending' ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
Envoyer le message
</button>
</form>
</div>
</div>
</div>
</div>
</div>
);
};
export default BusinessDetailClient;

File diff suppressed because it is too large Load Diff

View File

@@ -1,208 +1,76 @@
"use client";
import React from 'react';
import { prisma } from '@/lib/prisma';
import HomeClient from './HomeClient';
import { Metadata } from 'next';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
import { generateSlug } from '../lib/utils';
import { Business, BlogPost } from '../types';
import BusinessCard from '../components/BusinessCard';
import { useUser } from '../components/UserProvider';
export async function generateMetadata(): Promise<Metadata> {
const settings = await prisma.siteSetting.findUnique({
where: { id: 'singleton' }
});
const HomePage = () => {
const router = useRouter();
const { user, settings } = useUser();
const [searchTerm, setSearchTerm] = useState('');
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>([]);
const [posts, setPosts] = useState<BlogPost[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [loadingPosts, setLoadingPosts] = useState(true);
const title = settings?.siteName || 'Afroprenariat';
const description = settings?.siteSlogan || "La plateforme de référence pour l'entrepreneuriat africain.";
useEffect(() => {
const fetchFeatured = async () => {
try {
const res = await fetch('/api/businesses?featured=true', {
headers: user ? { 'x-user-id': user.id } : {}
});
const data = await res.json();
if (Array.isArray(data)) {
setFeaturedBusinesses(data.slice(0, 4)); // Show top 4
}
} catch (error) {
console.error('Error fetching featured businesses:', error);
} finally {
setLoading(false);
}
};
fetchFeatured();
const fetchPosts = async () => {
try {
const res = await fetch('/api/blog');
const data = await res.json();
if (Array.isArray(data)) {
setPosts(data);
}
} catch (error) {
console.error('Error fetching blog posts:', error);
} finally {
setLoadingPosts(false);
}
};
const fetchCategories = async () => {
try {
const res = await fetch('/api/categories');
const data = await res.json();
if (Array.isArray(data)) setCategories(data);
} catch (e) {}
};
fetchPosts();
fetchCategories();
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
router.push(`/annuaire?q=${searchTerm}`);
return {
title: {
default: title,
template: `%s | ${title}`
},
description: description,
openGraph: {
title: title,
description: description,
url: 'https://afroprenariat.com',
siteName: title,
images: [
{
url: 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&h=630&q=80',
width: 1200,
height: 630,
alt: title,
},
],
locale: 'fr_FR',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: title,
description: description,
images: ['https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&h=630&q=80'],
},
};
}
export default async function HomePage() {
// Fetch initial data for faster loading and SEO
const [featuredBusinesses, posts, categories] = await Promise.all([
prisma.business.findMany({
where: { isFeatured: true, isActive: true, isSuspended: false },
take: 4,
include: { categoryRef: true }
}),
prisma.blogPost.findMany({
where: { status: 'PUBLISHED', publishedAt: { lte: new Date() } },
orderBy: { date: 'desc' },
take: 6
}),
prisma.businessCategory.findMany({
where: { isActive: true },
take: 8
})
]);
// Convert to plain objects for client component
const initialFeatured = JSON.parse(JSON.stringify(featuredBusinesses));
const initialPosts = JSON.parse(JSON.stringify(posts));
const initialCategories = JSON.parse(JSON.stringify(categories));
return (
<div>
{/* Hero Section */}
<div className="relative bg-dark-900 overflow-hidden">
<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" />
</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 */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<h2 className="text-3xl font-bold text-gray-900 mb-10 font-serif text-center">Secteurs en vedette</h2>
<div className="flex flex-wrap justify-center gap-4 sm:gap-6">
{(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
<Link
key={idx}
href={`/annuaire?category=${encodeURIComponent(cat)}`}
className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center w-[calc(50%-0.5rem)] sm:w-48 lg:w-56 flex flex-col items-center"
>
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
<Briefcase className="w-6 h-6" />
</div>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base line-clamp-2">{cat}</h3>
</Link>
))}
</div>
</div>
{/* Featured Businesses */}
<div className="bg-gray-50 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">Entreprises à la une</h2>
<p className="text-gray-500 mt-2">Découvrez les pépites de notre communauté.</p>
</div>
<Link href="/annuaire" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
</Link>
</div>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-10 h-10 text-brand-600 animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{featuredBusinesses.map(biz => (
<BusinessCard key={biz.id} business={biz} />
))}
</div>
)}
</div>
</div>
{/* Blog Section */}
{settings?.homeBlogShow !== false && (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
</div>
<Link href="/blog" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout le blog <ArrowRight className="w-4 h-4 ml-1" />
</Link>
</div>
{loadingPosts ? (
<div className="flex justify-center py-12">
<Loader2 className="w-10 h-10 text-brand-600 animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts
.filter(post => {
if (settings?.homeBlogSelection === 'manual') {
return settings.homeBlogIds?.includes(post.id);
}
if (settings?.homeBlogSelection === 'category') {
return post.tags?.some((tag: string) => settings.homeBlogCategories?.includes(tag));
}
return true;
})
.slice(0, settings?.homeBlogCount || 3)
.map(post => (
<Link key={post.id} href={`/blog/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
<div className="h-48 overflow-hidden">
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
</div>
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-brand-600 transition-colors">{post.title}</h3>
<p className="text-gray-500 text-sm line-clamp-3 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between mt-auto pt-4 border-t border-gray-50">
<span className="text-xs font-medium text-gray-400">{new Date(post.date).toLocaleDateString('fr-FR')}</span>
<span className="text-brand-600 font-bold text-xs">Lire la suite </span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
)}
</div>
<HomeClient
initialFeatured={initialFeatured}
initialPosts={initialPosts}
initialCategories={initialCategories}
/>
);
};
export default HomePage;
}