feat: implement core platform features including business directory, admin dashboard, authentication, and API infrastructure
Some checks failed
Build and Push App / build (push) Failing after 16m2s

This commit is contained in:
2026-04-18 22:10:19 +02:00
parent 6ec1a3ae84
commit 3e2063e4fa
89 changed files with 4405 additions and 967 deletions

View File

@@ -3,7 +3,6 @@ import Link from 'next/link';
import { Play, FileText, Clock, Mic } from 'lucide-react';
import { prisma } from '../../lib/prisma';
import { InterviewType } from '@prisma/client';
import { MOCK_INTERVIEWS } from '../../lib/mockData';
export const revalidate = 3600;
@@ -28,11 +27,8 @@ export default async function AfroLifePage({ searchParams }: Props) {
orderBy: { createdAt: 'desc' }
});
} catch (error) {
console.error("Database connection failed on Afro Life page, using mock data.");
interviews = MOCK_INTERVIEWS.filter(i => filter === 'ALL' || i.type === filter).map(i => ({
...i,
createdAt: new Date(i.date),
}));
console.error("Database connection failed on Afro Life page.");
interviews = [];
}
return (

View File

@@ -4,9 +4,8 @@
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, Play, Share2, Send, Award, Quote, MessageCircle, Lock, Loader2 } from 'lucide-react';
import { MOCK_BUSINESSES, MOCK_OFFERS } from '../../../lib/mockData';
import { Business, OfferType } from '../../../types';
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Twitter, Play, Share2, Send, Award, Quote, MessageCircle, Lock, Loader2, Link2, ShoppingBag } from 'lucide-react';
import { Business, OfferType, Rating } from '../../../types';
import { toast } from 'react-hot-toast';
import { useUser } from '../../../components/UserProvider';
@@ -17,6 +16,22 @@ const BusinessDetailPage = () => {
const [business, setBusiness] = useState<Business | null>(null);
const [loading, setLoading] = useState(true);
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 isOwner = user && business && user.id === business.ownerId;
const contactRef = React.useRef<HTMLDivElement>(null);
const [contactForm, setContactForm] = useState({
name: '',
@@ -25,13 +40,30 @@ const BusinessDetailPage = () => {
});
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);
// Scroll to contact form
contactRef.current?.scrollIntoView({ behavior: 'smooth' });
// Focus textarea after scroll
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 the business has its own offers (from DB), use them
if (business.offers && business.offers.length > 0) return business.offers;
// Otherwise fallback to mock offers for mock businesses
return MOCK_OFFERS.filter(o => o.businessId === id && o.active);
}, [business, id]);
return [];
}, [business]);
useEffect(() => {
window.scrollTo(0, 0);
@@ -39,13 +71,7 @@ const BusinessDetailPage = () => {
const loadBusiness = async () => {
setLoading(true);
// 1. Try Mock Data First (Instant)
const mock = MOCK_BUSINESSES.find(b => b.id === id);
if (mock) {
setBusiness(mock);
setLoading(false);
return;
}
// Sync with DB directly
// 2. Try API (Database)
try {
@@ -86,6 +112,37 @@ const BusinessDetailPage = () => {
} finally {
setLoading(false);
}
// 4. Fetch user rating if logged in
if (user) {
try {
const ratingRes = await fetch(`/api/businesses/${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('Error fetching user rating:', e);
}
}
// 5. Fetch all ratings
try {
setLoadingRatings(true);
const ratingsRes = await fetch(`/api/businesses/${id}/ratings`);
if (ratingsRes.ok) {
const ratingsData = await ratingsRes.json();
setRatings(ratingsData);
}
} catch (e) {
console.error('Error fetching ratings list:', e);
} finally {
setLoadingRatings(false);
}
};
if (id) loadBusiness();
@@ -213,15 +270,151 @@ const BusinessDetailPage = () => {
}
};
const handleShare = (platform: string) => {
if (!business) return;
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 'instagram':
toast("Pour partager sur Instagram, copiez le lien ou faites une capture d'écran.", { icon: '📸' });
setIsShareOpen(false);
return;
case 'copy':
navigator.clipboard.writeText(window.location.href);
toast.success("Lien copié dans le presse-papier !");
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/${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 ? {
...prev,
rating: data.rating,
ratingCount: data.ratingCount
} : null);
toast.success('Merci pour votre vote !');
handleTrackEvent('BUSINESS_RATING_CAST', `Stars: ${value}`);
// Refresh ratings list
const ratingsRes = await fetch(`/api/businesses/${id}/ratings`);
if (ratingsRes.ok) {
const ratingsData = await ratingsRes.json();
setRatings(ratingsData);
}
} else {
toast.error(data.error);
}
} catch (error) {
toast.error("Erreur lors de l'enregistrement du vote");
} finally {
setIsRating(false);
}
};
const handleReply = async (ratingId: string) => {
if (!user || !isOwner) return;
if (!replyText.trim()) {
toast.error("La réponse ne peut pas être vide");
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('');
// Refresh ratings
const ratingsRes = await fetch(`/api/businesses/${id}/ratings`);
if (ratingsRes.ok) {
const ratingsData = await ratingsRes.json();
setRatings(ratingsData);
}
} else {
const data = await res.json();
toast.error(data.error || "Erreur lors de la réponse");
}
} catch (e) {
toast.error("Erreur de connexion");
} finally {
setIsSubmittingReply(false);
}
};
return (
<div className="bg-gray-50 min-h-screen pb-12">
{/* Header Banner */}
<div className="h-48 md:h-64 bg-gradient-to-r from-gray-900 to-gray-800 relative overflow-hidden">
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')]"></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">
<Link href="/annuaire" className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit">
<ArrowLeft className="w-4 h-4 mr-2" /> Retour
</Link>
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] pointer-events-none"></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={() => {
if (window.history.length > 1) {
router.back();
} else {
router.push('/annuaire');
}
}}
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>
@@ -255,10 +448,61 @@ const BusinessDetailPage = () => {
</div>
)}
</div>
<div className="flex gap-3">
<button 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">
<Share2 className="w-4 h-4 mr-2" /> Partager
</button>
<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"
onMouseLeave={() => setIsShareOpen(false)}
>
<div className="px-4 py-2 text-xs font-bold text-gray-400 uppercase tracking-widest border-b border-gray-50 mb-1">Partager la fiche</div>
<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 transition-colors">
<div className="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center mr-3 text-blue-600">
<Facebook className="w-4 h-4" />
</div>
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 transition-colors">
<div className="w-8 h-8 rounded-full bg-gray-50 flex items-center justify-center mr-3 text-black">
<Twitter className="w-4 h-4" />
</div>
X (Twitter)
</button>
<button onClick={() => handleShare('linkedin')} 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-700 transition-colors">
<div className="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center mr-3 text-blue-700">
<Linkedin className="w-4 h-4" />
</div>
LinkedIn
</button>
<button onClick={() => handleShare('instagram')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-pink-600 transition-colors text-gray-400">
<div className="w-8 h-8 rounded-full bg-pink-50 flex items-center justify-center mr-3 text-pink-600">
<Instagram className="w-4 h-4" />
</div>
Instagram
</button>
<div className="h-px bg-gray-100 my-1 mx-4"></div>
<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 transition-colors">
<div className="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center mr-3">
<Link2 className="w-4 h-4" />
</div>
Copier le lien
</button>
</div>
)}
</div>
<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>
@@ -270,10 +514,34 @@ const BusinessDetailPage = () => {
<MapPin className="w-4 h-4 mr-1 text-gray-400" />
{business.location}
</div>
<div className="flex items-center">
<Star className="w-4 h-4 mr-1 text-yellow-400 fill-current" />
<span className="font-bold text-gray-700 mr-1">{business.rating}</span>
({business.viewCount} vues)
<div className="flex items-center gap-2 group/rating">
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
disabled={isRating}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(null)}
onClick={() => handleRate(star)}
className={`p-0.5 transition-all duration-200 ${!user ? 'cursor-default' : 'hover:scale-125 cursor-pointer active:scale-95'}`}
title={user ? `Voter ${star} étoiles` : "Connectez-vous pour voter"}
>
<Star
className={`w-5 h-5 transition-colors ${
(hoverRating !== null ? star <= hoverRating : (userRating !== null ? star <= userRating : star <= Math.round(business.rating)))
? 'text-yellow-400 fill-current'
: 'text-gray-300'
} ${isRating ? 'animate-pulse' : ''}`}
/>
</button>
))}
</div>
<div className="flex items-center text-sm">
<span className="font-bold text-gray-900 mr-1">{business.rating.toFixed(1)}</span>
<span className="text-gray-400">
({(business as any).ratingCount || 0} votes {business.viewCount} vues)
</span>
</div>
</div>
{business.tags.map(tag => (
<span key={tag} className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">#{tag}</span>
@@ -351,20 +619,24 @@ const BusinessDetailPage = () => {
<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-shadow">
<div className="h-40 bg-gray-100 relative">
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover" />
<div
key={offer.id}
onClick={() => setSelectedOffer(offer)}
className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-all cursor-pointer group hover:scale-[1.01]"
>
<div className="h-40 bg-gray-100 relative overflow-hidden">
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
<span className={`absolute top-2 right-2 px-2 py-1 text-xs font-bold rounded uppercase ${offer.type === OfferType.PRODUCT ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'}`}>
{offer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
</span>
</div>
<div className="p-4">
<h3 className="font-bold text-gray-900 line-clamp-1">{offer.title}</h3>
<h3 className="font-bold text-gray-900 line-clamp-1 group-hover:text-brand-600 transition-colors">{offer.title}</h3>
<p className="text-brand-600 font-bold mt-1">
{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}
</p>
<button className="mt-3 w-full block text-center bg-gray-50 text-gray-700 py-2 rounded text-sm font-medium hover:bg-gray-100 transition-colors">
Commander
<button className="mt-3 w-full block text-center bg-gray-50 text-gray-700 py-2 rounded text-sm font-medium hover:bg-brand-50 hover:text-brand-700 transition-colors">
Détails & Commander
</button>
</div>
</div>
@@ -372,12 +644,216 @@ const BusinessDetailPage = () => {
</div>
</div>
)}
{/* Reviews Section */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-bold font-serif text-gray-900 flex items-center gap-2">
<MessageCircle className="w-5 h-5 text-brand-600" />
Avis & Témoignages
<span className="text-sm font-normal text-gray-400 font-sans ml-2">
({ratings.length} avis)
</span>
</h2>
<div className="flex items-center gap-2">
<div className="flex text-yellow-400">
{[1, 2, 3, 4, 5].map((s) => (
<Star key={s} className={`w-4 h-4 ${s <= Math.round(business.rating) ? 'fill-current' : 'text-gray-200'}`} />
))}
</div>
<span className="font-bold text-gray-900">{business.rating.toFixed(1)}</span>
</div>
</div>
{/* Leave a Review Form */}
<div className="mb-10 p-6 bg-brand-50/50 rounded-2xl border border-brand-100/50">
<h3 className="text-sm font-bold text-brand-900 mb-4 flex items-center gap-2">
{isOwner ? "Votre Fiche" : (userRating ? "Modifier votre avis" : "Laisser un avis")}
</h3>
{isOwner ? (
<p className="text-sm text-brand-600 bg-white p-4 rounded-xl border border-brand-100 italic">
Vous consultez votre propre fiche. Vous pouvez répondre aux avis des clients ci-dessous.
</p>
) : (
<div className="space-y-4">
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Votre Note</label>
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
disabled={isRating}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(null)}
onClick={() => handleRate(star)}
className="transition-all duration-200 hover:scale-125 focus:outline-none"
>
<Star
className={`w-8 h-8 transition-colors ${
(hoverRating !== null ? star <= hoverRating : (userRating !== null ? star <= userRating : false))
? 'text-yellow-400 fill-current'
: 'text-gray-300'
}`}
/>
</button>
))}
{userRating && <span className="ml-2 text-sm font-medium text-brand-600">Vous avez mis {userRating} étoiles</span>}
</div>
{userRatingStatus === 'PENDING' && (
<div className="flex items-center gap-2 bg-amber-50 text-amber-700 px-3 py-2 rounded-lg border border-amber-100 mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<p className="text-[11px] font-medium leading-tight">
Votre avis est reçu ! Il est actuellement <span className="font-bold underline">en cours de modération</span> par notre équipe pour assurer la qualité des retours.
</p>
</div>
)}
{userRating && userRatingStatus !== 'PENDING' && <p className="text-[10px] text-gray-400 italic">Conformément à nos règles, la note peut uniquement être réévaluée à la hausse.</p>}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Votre Commentaire</label>
<textarea
value={reviewComment}
onChange={(e) => setReviewComment(e.target.value)}
placeholder={ratings.some(r => r.userId === user?.id && r.comment) ? "Le commentaire ne peut plus être modifié une fois posté." : "Partagez votre expérience avec cet entrepreneur..."}
className={`w-full px-4 py-3 bg-white border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500 outline-none transition-all resize-none shadow-sm ${ratings.some(r => r.userId === user?.id && r.comment) ? 'opacity-60 cursor-not-allowed bg-gray-50' : ''}`}
rows={3}
disabled={!user || isRating || ratings.some(r => r.userId === user?.id && r.comment)}
/>
{ratings.some(r => r.userId === user?.id && r.comment) && (
<p className="text-[10px] text-brand-600 font-medium">Votre avis est désormais immuable. Seul le score peut changer.</p>
)}
</div>
<div className="flex justify-between items-center">
{!user && (
<p className="text-xs text-brand-600 italic flex items-center gap-1">
<Lock className="w-3 h-3" /> Connectez-vous pour laisser un commentaire
</p>
)}
{user && (
<button
onClick={() => userRating ? handleRate(userRating) : toast.error("Veuillez d'abord sélectionner une note")}
disabled={isRating}
className="ml-auto bg-brand-600 text-white px-6 py-2 rounded-lg text-sm font-bold shadow-md hover:bg-brand-700 transition-all disabled:opacity-50 flex items-center gap-2"
>
{isRating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
{userRating ? "Mettre à jour la note" : "Publier l'avis"}
</button>
)}
</div>
</div>
)}
</div>
{/* Reviews List */}
<div className="space-y-6">
{loadingRatings ? (
<div className="py-10 text-center">
<Loader2 className="w-8 h-8 animate-spin text-brand-500 mx-auto" />
<p className="text-gray-400 text-sm mt-2">Chargement des avis...</p>
</div>
) : ratings.length > 0 ? (
ratings.map((r: any) => (
<div key={r.id} className="group border-b border-gray-50 pb-6 last:border-0">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-brand-100 flex-shrink-0 flex items-center justify-center font-bold text-brand-700 overflow-hidden border border-brand-50 shadow-sm">
{r.user?.avatar ? (
<img src={r.user.avatar} alt={r.user.name} className="w-full h-full object-cover" />
) : (
r.user?.name?.charAt(0) || '?'
)}
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<h4 className="text-sm font-bold text-gray-900">{r.user?.name}</h4>
<span className="text-[10px] text-gray-400 uppercase tracking-tighter">
{new Date(r.createdAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
</div>
<div className="flex text-yellow-400 mb-2">
{[1, 2, 3, 4, 5].map((s) => (
<Star key={s} className={`w-3 h-3 ${s <= r.value ? 'fill-current' : 'text-gray-200'}`} />
))}
</div>
{r.comment ? (
<p className="text-gray-600 text-sm leading-relaxed bg-gray-50/50 p-3 rounded-lg border border-gray-50 group-hover:bg-white group-hover:shadow-sm transition-all">
{r.comment}
</p>
) : (
<p className="text-gray-400 text-xs italic">Note attribuée sans commentaire.</p>
)}
{/* Entrepreneur Reply */}
{r.reply ? (
<div className="mt-4 ml-4 pl-4 border-l-2 border-brand-200 bg-brand-50/30 p-3 rounded-r-lg">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-brand-700 uppercase tracking-widest">Réponse de l'entrepreneur</span>
<span className="text-[9px] text-gray-400 uppercase">
{new Date(r.replyAt!).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}
</span>
</div>
<p className="text-sm text-gray-700 italic">"{r.reply}"</p>
</div>
) : isOwner && (
<div className="mt-3">
{replyingTo === r.id ? (
<div className="space-y-2 animate-in slide-in-from-top-2 duration-200">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Votre réponse..."
className="w-full px-3 py-2 bg-white border border-brand-200 rounded-lg text-sm focus:ring-1 focus:ring-brand-500 outline-none transition-all resize-none shadow-sm"
rows={2}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setReplyingTo(null)}
className="text-xs text-gray-500 hover:text-gray-700"
>
Annuler
</button>
<button
onClick={() => handleReply(r.id)}
disabled={isSubmittingReply || !replyText.trim()}
className="bg-brand-600 text-white px-3 py-1 rounded text-xs font-bold hover:bg-brand-700 transition-all disabled:opacity-50"
>
{isSubmittingReply ? "Envoi..." : "Répondre"}
</button>
</div>
</div>
) : (
<button
onClick={() => setReplyingTo(r.id)}
className="text-xs font-bold text-brand-600 hover:text-brand-700 flex items-center gap-1 group/btn"
>
<MessageCircle className="w-3 h-3 transition-transform group-hover/btn:scale-110" />
Répondre à cet avis
</button>
)}
</div>
)}
</div>
</div>
</div>
))
) : (
<div className="py-12 text-center bg-gray-50/50 rounded-2xl border border-dashed border-gray-200">
<MessageCircle className="w-10 h-10 text-gray-200 mx-auto mb-3" />
<p className="text-gray-500 font-medium">Aucun avis pour le moment.</p>
<p className="text-gray-400 text-xs mt-1">Soyez le premier à partager votre expérience !</p>
</div>
)}
</div>
</div>
</div>
{/* Right Column: Sidebar */}
<div className="space-y-8">
{/* Contact Card */}
<div id="contact-form" className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 sticky top-24">
<div ref={contactRef} id="contact-form" className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 sticky top-24">
<h3 className="text-lg font-bold text-gray-900 mb-4">Contact</h3>
<div className="space-y-4 mb-6">
@@ -457,6 +933,7 @@ const BusinessDetailPage = () => {
<form onSubmit={handleContactSubmit} className="space-y-4">
<div className="relative">
<textarea
id="contact-message"
placeholder={user ? "Bonjour, j'aimerais en savoir plus sur..." : "Connectez-vous pour envoyer un message"}
required
rows={4}
@@ -522,29 +999,80 @@ const BusinessDetailPage = () => {
</div>
{/* Similar Businesses (Mock) */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4">Dans la même catégorie</h3>
<div className="space-y-4">
{MOCK_BUSINESSES
.filter(b => b.category === business.category && b.id !== business.id)
.slice(0, 3)
.map(similar => (
<Link key={similar.id} href={`/annuaire/${similar.id}`} className="flex items-center group">
<div className="w-12 h-12 rounded bg-gray-100 overflow-hidden flex-shrink-0">
<img src={similar.logoUrl} alt={similar.name} className="w-full h-full object-cover" />
</div>
<div className="ml-3 overflow-hidden">
<p className="text-sm font-medium text-gray-900 truncate group-hover:text-brand-600 transition-colors">{similar.name}</p>
<p className="text-xs text-gray-500 truncate">{similar.location}</p>
</div>
</Link>
))
}
{/* Similar Businesses (Disabled - Mock data removed) */}
{false && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4">Dans la même catégorie</h3>
<div className="space-y-4">
</div>
</div>
)}
</div>
</div>
</div>
{/* Offer Detail Modal */}
{selectedOffer && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300"
onClick={() => setSelectedOffer(null)}
></div>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden relative z-10 animate-in zoom-in slide-in-from-bottom-4 duration-300">
{/* Close button */}
<button
onClick={() => setSelectedOffer(null)}
className="absolute top-4 right-4 p-2 bg-white/80 backdrop-blur rounded-full text-gray-500 hover:text-gray-900 shadow-md transition-all z-20"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex flex-col md:flex-row h-full">
<div className="md:w-1/2 h-64 md:h-auto relative">
<img src={selectedOffer.imageUrl} alt={selectedOffer.title} className="w-full h-full object-cover" />
<div className="absolute top-4 left-4">
<span className={`px-3 py-1 text-xs font-bold rounded-full shadow-lg ${selectedOffer.type === OfferType.PRODUCT ? 'bg-blue-600 text-white' : 'bg-purple-600 text-white'}`}>
{selectedOffer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
</span>
</div>
</div>
<div className="md:w-1/2 p-6 md:p-8 flex flex-col">
<h2 className="text-2xl font-bold font-serif text-gray-900 mb-2">{selectedOffer.title}</h2>
<p className="text-2xl font-bold text-brand-600 mb-6">
{new Intl.NumberFormat('fr-FR').format(selectedOffer.price)} {selectedOffer.currency}
</p>
<div className="flex-1 overflow-y-auto pr-2 mb-8">
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-3">Description</h4>
<p className="text-gray-600 text-sm leading-relaxed whitespace-pre-line">
{selectedOffer.description || "Aucune description détaillée n'est disponible pour ce produit/service."}
</p>
</div>
<div className="space-y-3">
<button
onClick={() => handleOrder(selectedOffer)}
className="w-full bg-brand-600 text-white font-bold py-3 px-6 rounded-xl hover:bg-brand-700 shadow-lg shadow-brand-100 transition-all flex items-center justify-center gap-3 active:scale-95"
>
<ShoppingBag className="w-5 h-5" />
Commander maintenant
</button>
<button
onClick={() => setSelectedOffer(null)}
className="w-full bg-gray-50 text-gray-500 font-medium py-2 px-6 rounded-lg hover:bg-gray-100 transition-colors text-sm"
>
Fermer
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -4,26 +4,44 @@
import React, { useState, useEffect, useMemo, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Search, Loader2 } from 'lucide-react';
import { CATEGORIES, Business } from '../../types';
import { CATEGORIES, Business, Country } from '../../types';
import BusinessCard from '../../components/BusinessCard';
import AnnuaireHero from '../../components/AnnuaireHero';
const AnnuairePageContent = () => {
const [businesses, setBusinesses] = useState<Business[]>([]);
const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null);
const [countries, setCountries] = useState<Country[]>([]);
const [loading, setLoading] = useState(true);
const [filterCategory, setFilterCategory] = useState('All');
const [filterCountry, setFilterCountry] = useState('All');
const [searchQuery, setSearchQuery] = useState('');
const searchParams = useSearchParams();
// 1. Fetch Featured Headliner once
// 1. Fetch Countries
useEffect(() => {
const fetchCountries = async () => {
try {
const res = await fetch('/api/countries');
const data = await res.json();
if (Array.isArray(data)) setCountries(data);
} catch (e) {}
};
fetchCountries();
}, []);
// 2. Fetch Featured Headliner once
useEffect(() => {
const fetchHeadliner = async () => {
try {
const res = await fetch('/api/businesses?featured=true');
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
setFeaturedBusiness(data[0]);
// Stable random based on the current date (YYYYMMDD)
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const seed = parseInt(today);
const randomIndex = seed % data.length;
setFeaturedBusiness(data[randomIndex]);
}
} catch (error) {
console.error('Error fetching headliner:', error);
@@ -32,13 +50,14 @@ const AnnuairePageContent = () => {
fetchHeadliner();
}, []);
// 2. Fetch businesses from API
// 3. Fetch businesses from API
useEffect(() => {
const fetchBusinesses = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filterCategory !== 'All') params.append('category', filterCategory);
if (filterCountry !== 'All') params.append('countryId', filterCountry);
if (searchQuery) params.append('q', searchQuery);
const res = await fetch(`/api/businesses?${params.toString()}`);
@@ -55,11 +74,17 @@ const AnnuairePageContent = () => {
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
return () => clearTimeout(timeoutId);
}, [filterCategory, searchQuery]);
}, [filterCategory, searchQuery, filterCountry]);
useEffect(() => {
const q = searchParams.get('q');
if (q) setSearchQuery(q);
const cat = searchParams.get('category');
if (cat) setFilterCategory(cat);
const country = searchParams.get('country');
if (country) setFilterCountry(country);
}, [searchParams]);
const filteredBusinesses = businesses;
@@ -87,9 +112,23 @@ const AnnuairePageContent = () => {
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">Pays</label>
<select
value={filterCountry}
onChange={(e) => setFilterCountry(e.target.value)}
className="w-full border-gray-300 rounded-md shadow-sm focus:ring-brand-500 focus:border-brand-500 sm:text-sm p-2 border"
>
<option value="All">Tous les pays</option>
{countries.map(c => (
<option key={c.id} value={c.id}>{c.flag} {c.name}</option>
))}
</select>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">Catégorie</label>
<div className="space-y-2">
<div className="space-y-1 max-h-60 overflow-y-auto pr-1">
<div className="flex items-center">
<input
id="cat-all"

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/admin/reports — List all reports for moderation
export async function GET(request: NextRequest) {

View File

@@ -37,14 +37,57 @@ export async function GET(
// 3. Get monthly stats (views per day for last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
thirtyDaysAgo.setHours(0, 0, 0, 0);
// Note: Raw query might be needed for group by date in some DBs,
// but for now we'll just return the totals for simplicity.
const recentEvents = await prisma.analyticsEvent.findMany({
where: {
createdAt: { gte: thirtyDaysAgo },
metadata: {
path: ['businessId'],
equals: id
}
},
select: {
type: true,
createdAt: true
},
orderBy: {
createdAt: 'asc'
}
});
// 4. Process events into daily stats
const statsMap = new Map<string, { date: string, views: number, clicks: number }>();
// Initialize last 30 days with 0
for (let i = 0; i <= 30; i++) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const displayDate = date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
statsMap.set(dateStr, { date: displayDate, views: 0, clicks: 0 });
}
recentEvents.forEach(event => {
const dateStr = event.createdAt.toISOString().split('T')[0];
if (statsMap.has(dateStr)) {
const dayStat = statsMap.get(dateStr)!;
if (event.type === 'BUSINESS_VIEW') {
dayStat.views++;
} else if (event.type === 'BUSINESS_CONTACT_CLICK') {
dayStat.clicks++;
}
}
});
// Convert Map to sorted array
const dailyStats = Array.from(statsMap.values()).reverse(); // Older to newer
return NextResponse.json({
businessId: id,
totalViews,
contactClicks,
dailyStats,
updatedAt: new Date().toISOString()
});

View File

@@ -1,17 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import prisma from '@/lib/prisma';
import { getGeoInfo } from '@/lib/geo';
export async function POST(request: NextRequest) {
console.log('>>> ANALYTICS API HIT');
try {
const body = await request.json();
console.log('>>> PAYLOAD:', JSON.stringify(body));
const { type, path, label, value, metadata } = body;
if (!type || !path) {
return NextResponse.json({ error: 'Type et Path requis' }, { status: 400 });
}
// Extract detailed info from headers
const userAgent = request.headers.get('user-agent') || '';
const referrer = request.headers.get('referer') || '';
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || '127.0.0.1';
// Basic User-Agent parsing
const isMobile = /mobile/i.test(userAgent);
const device = isMobile ? 'Mobile' : 'Desktop';
let browser = 'Other';
if (/chrome|crios/i.test(userAgent)) browser = 'Chrome';
else if (/firefox|fxios/i.test(userAgent)) browser = 'Firefox';
else if (/safari/i.test(userAgent)) browser = 'Safari';
else if (/edge/i.test(userAgent)) browser = 'Edge';
let os = 'Other';
if (/windows/i.test(userAgent)) os = 'Windows';
else if (/macintosh/i.test(userAgent)) os = 'macOS';
else if (/android/i.test(userAgent)) os = 'Android';
else if (/iphone|ipad/i.test(userAgent)) os = 'iOS';
// Simple Country detection
const { country, city } = await getGeoInfo(ip, request.headers);
const event = await prisma.analyticsEvent.create({
data: {
type,
@@ -19,6 +42,13 @@ export async function POST(request: NextRequest) {
label: label || null,
value: value || null,
metadata: metadata || {},
ip,
country,
browser,
os,
device,
referrer,
userId: metadata?.userId || null,
},
});

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/blog
export async function GET() {

View File

@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id: paramId } = await context.params;
const userId = req.headers.get('x-user-id');
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
}
try {
const { value, comment } = await req.json();
if (!value || value < 1 || value > 5) {
return NextResponse.json({ error: 'Valeur de note invalide (1-5)' }, { status: 400 });
}
// 1. Resolve actual Business UUID if paramId is a slug
const business = await prisma.business.findFirst({
where: {
OR: [
{ id: paramId },
{ slug: paramId }
]
},
select: { id: true }
});
if (!business) {
return NextResponse.json({ error: "Les données de démonstration ne peuvent pas recevoir d'avis. Veuillez créer votre propre entreprise pour tester." }, { status: 403 });
}
const businessId = business.id;
const ownerId = (business as any).ownerId;
// 2. Security: Don't allow owner to rate themselves
if (userId === ownerId) {
return NextResponse.json({ error: "Vous ne pouvez pas noter votre propre entreprise." }, { status: 403 });
}
// 3. Fetch existing rating to check rules
const existingRating = await prisma.rating.findUnique({
where: {
userId_businessId: {
userId,
businessId
}
}
});
if (existingRating) {
// Rule: Score can only be re-evaluated UPWARDS
if (value < existingRating.value) {
return NextResponse.json({
error: `La note ne peut être réévaluée qu'à la hausse. Votre note actuelle est de ${existingRating.value} étoiles.`
}, { status: 400 });
}
// Rule: Comment is IMMUTABLE once set
// We only update the value. If the comment was empty, we can set it once.
await prisma.rating.update({
where: { id: existingRating.id },
data: {
value,
comment: existingRating.comment ? undefined : comment, // Only set if it was null
status: 'PENDING' // Reset to pending for re-moderation
}
});
} else {
// 4. Create new rating
await prisma.rating.create({
data: {
value,
comment,
userId,
businessId,
status: 'PENDING'
}
});
}
// 3. Aggregate all ratings for this business
const stats = await prisma.rating.aggregate({
where: { businessId },
_avg: { value: true },
_count: { value: true }
});
const newRating = stats._avg.value || 0;
const newRatingCount = stats._count.value || 0;
// 4. Update the business
const updatedBusiness = await prisma.business.update({
where: { id: businessId },
data: {
rating: newRating,
ratingCount: newRatingCount
}
});
return NextResponse.json({
rating: updatedBusiness.rating,
ratingCount: updatedBusiness.ratingCount,
message: 'Vote enregistré avec succès'
});
} catch (error: any) {
console.error('Error rating business:', error);
return NextResponse.json({ error: 'Erreur serveur lors du vote' }, { status: 500 });
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id: paramId } = await context.params;
const userId = req.headers.get('x-user-id');
if (!userId) {
return NextResponse.json({ rating: null });
}
try {
// Resolve actual Business UUID if paramId is a slug
const business = await prisma.business.findFirst({
where: {
OR: [
{ id: paramId },
{ slug: paramId }
]
},
select: { id: true }
});
if (!business) {
return NextResponse.json({ rating: null });
}
const rating = await prisma.rating.findUnique({
where: {
userId_businessId: {
userId,
businessId: business.id
}
}
});
return NextResponse.json({
rating: rating ? rating.value : null,
status: rating ? rating.status : null,
comment: rating ? rating.comment : null
});
} catch (error: any) {
console.error('Error fetching user rating:', error);
return NextResponse.json({ error: 'Erreur lors de la récupération de votre note' }, { status: 500 });
}
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id: paramId } = await context.params;
try {
// 1. Resolve actual Business UUID if paramId is a slug
const business = await prisma.business.findFirst({
where: {
OR: [
{ id: paramId },
{ slug: paramId }
]
},
select: { id: true }
});
if (!business) {
// Return empty array for mock/missing businesses instead of error
return NextResponse.json([]);
}
const businessId = business.id;
// 2. Fetch all approved ratings with user info
const ratings = await prisma.rating.findMany({
where: {
businessId,
status: 'APPROVED'
},
include: {
user: {
select: {
id: true,
name: true,
avatar: true
}
}
},
orderBy: {
createdAt: 'desc'
}
});
return NextResponse.json(ratings);
} catch (error: any) {
console.error('Error fetching ratings:', error);
return NextResponse.json({ error: 'Erreur serveur lors de la récupération des avis' }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import prisma from '@/lib/prisma'
type Params = { params: Promise<{ id: string }> }
@@ -49,12 +49,48 @@ export async function PATCH(
// DELETE /api/businesses/[id]
export async function DELETE(
_request: NextRequest,
request: NextRequest,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const { id } = await context.params
const headerUserId = request.headers.get('x-user-id')
// 1. Find business to check owner
const business = await prisma.business.findUnique({
where: { id },
select: { ownerId: true }
})
if (!business) {
return NextResponse.json({ error: 'Boutique non trouvée' }, { status: 404 })
}
// 2. Auth check (Owner or Admin)
const user = headerUserId ? await prisma.user.findUnique({ where: { id: headerUserId } }) : null
const isAdmin = user?.role === 'ADMIN'
const isOwner = business.ownerId === headerUserId
if (!isOwner && !isAdmin) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
}
// 3. Delete business
await prisma.business.delete({ where: { id } })
// 4. Update user role if they don't have other businesses
if (headerUserId && !isAdmin) {
const otherBusinesses = await prisma.business.findFirst({
where: { ownerId: headerUserId }
})
if (!otherBusinesses) {
await prisma.user.update({
where: { id: headerUserId },
data: { role: 'VISITOR' }
})
}
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('DELETE /api/businesses/[id] error:', error)

View File

@@ -1,6 +1,6 @@
// Force refresh for Next.js 16 params fix
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
import prisma from '@/lib/prisma';
export async function POST(
request: NextRequest,
@@ -13,10 +13,27 @@ export async function POST(
return NextResponse.json({ error: 'ID requis' }, { status: 400 });
}
// Attempt to increment in DB
// Note: We don't care about mock businesses here as they are static
// 1. Resolve actual Business UUID if id is a slug
const businessFound = await prisma.business.findFirst({
where: {
OR: [
{ id },
{ slug: id }
]
},
select: { id: true }
});
if (!businessFound) {
// If business not found in DB (might be mock), just return success but no update
return NextResponse.json({ success: true, message: 'Mock business, skipping view count' });
}
const businessId = businessFound.id;
// 2. Attempt to increment in DB
const business = await prisma.business.update({
where: { id },
where: { id: businessId },
data: {
viewCount: {
increment: 1,
@@ -26,8 +43,7 @@ export async function POST(
return NextResponse.json({ success: true, viewCount: business.viewCount });
} catch (error) {
// If business not found in DB (might be mock), just return success but no update
console.error('Error incrementing view count:', error);
return NextResponse.json({ success: false, error: 'Business not found or error' }, { status: 404 });
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/businesses/me — Fetch business for the current user
export async function GET(request: NextRequest) {

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import { MOCK_BUSINESSES } from '../../../lib/mockData'
import prisma from '@/lib/prisma'
// GET /api/businesses — List all businesses (Mock + DB)
export async function GET(request: NextRequest) {
@@ -9,6 +8,7 @@ export async function GET(request: NextRequest) {
const category = searchParams.get('category')
const q = searchParams.get('q')?.toLowerCase()
const featured = searchParams.get('featured')
const countryId = searchParams.get('countryId')
// 1. Fetch from Database
const where: any = {
@@ -20,6 +20,7 @@ export async function GET(request: NextRequest) {
}
if (category && category !== 'All') where.category = category
if (featured === 'true') where.isFeatured = true
if (countryId) where.countryId = countryId
if (q) {
where.OR = [
{ name: { contains: q, mode: 'insensitive' } },
@@ -35,31 +36,8 @@ export async function GET(request: NextRequest) {
orderBy: { createdAt: 'desc' },
})
// 2. Filter Mock Businesses
const filteredMocks = MOCK_BUSINESSES.filter(biz => {
// Category filter
if (category && category !== 'All' && biz.category !== category) return false;
// Featured filter
if (featured === 'true' && !biz.isFeatured) return false;
// Search query filter
if (q) {
const matches =
biz.name.toLowerCase().includes(q) ||
biz.description.toLowerCase().includes(q) ||
biz.tags.some(t => t.toLowerCase().includes(q));
if (!matches) return false;
}
return true;
});
// 3. Combine and return
// Prioritize DB entries over mocks to show user-created data first
const combined = [...dbBusinesses, ...filteredMocks];
return NextResponse.json(combined)
// 2. Return DB results
return NextResponse.json(dbBusinesses)
} catch (error) {
console.error('GET /api/businesses error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
@@ -84,6 +62,8 @@ export async function POST(request: NextRequest) {
slug: data.slug || null,
category: data.category || "Autre",
location: data.location || "",
countryId: data.countryId || null,
city: data.city || "",
description: data.description || "",
logoUrl: data.logoUrl || "https://picsum.photos/200/200?random=default",
videoUrl: data.videoUrl || null,
@@ -107,7 +87,7 @@ export async function POST(request: NextRequest) {
// 1b. Calculate isActive based on mandatory fields
const isNameOk = cleanData.name && cleanData.name !== "Nouvelle Entreprise";
const isDescOk = cleanData.description && cleanData.description.length >= 20;
const isLocOk = cleanData.location && cleanData.location !== "Ma Ville";
const isLocOk = (cleanData.countryId && cleanData.city) || (cleanData.location && cleanData.location !== "Ma Ville");
const isEmailOk = !!cleanData.contactEmail;
const isPhoneOk = !!cleanData.contactPhone;

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/conversations — List all conversations for the user
export async function GET(request: NextRequest) {

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const countries = await prisma.country.findMany({
where: { isActive: true },
orderBy: { name: 'asc' }
});
return NextResponse.json(countries);
} catch (error) {
console.error('GET /api/countries error:', error);
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}

View File

@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
if (action === 'description') {
const prompt = `
Tu es un expert en copywriting marketing pour Afropreunariat.
Tu es un expert en copywriting marketing pour Afrohub.
Rédige une description professionnelle, attrayante et optimisée SEO pour une entreprise.
Nom de l'entreprise : ${name}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/interviews
export async function GET() {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// POST /api/messages — Send a message or start a conversation
export async function POST(request: NextRequest) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import prisma from '@/lib/prisma'
export async function GET(request: NextRequest) {
try {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/offers — optionally filter by businessId
export async function GET(request: NextRequest) {
@@ -26,6 +26,44 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { businessId } = body
if (!businessId) {
return NextResponse.json({ error: 'businessId manquant' }, { status: 400 })
}
// 1. Fetch business and their plan's limit
const business = await prisma.business.findUnique({
where: { id: businessId },
include: {
_count: { select: { offers: true } }
}
})
if (!business) {
return NextResponse.json({ error: 'Entreprise non trouvée' }, { status: 404 })
}
// Fetch the specific limit for this tier from the database
const planDetails = await prisma.pricingPlan.findUnique({
where: { tier: business.plan }
})
if (!planDetails) {
return NextResponse.json({ error: 'Configuration du forfait introuvable' }, { status: 500 })
}
// 2. Enforce limits
const currentCount = business._count.offers
const offerLimit = planDetails.offerLimit
if (currentCount >= offerLimit) {
return NextResponse.json({
error: `Limite d'offres atteinte (${offerLimit} max pour le plan ${planDetails.name}). Veuillez passer au plan supérieur pour publier plus d'offres.`
}, { status: 403 })
}
// 3. Create offer
const offer = await prisma.offer.create({
data: body,
include: { business: true },

14
app/api/plans/route.ts Normal file
View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const plans = await prisma.pricingPlan.findMany({
orderBy: { offerLimit: 'asc' }
});
return NextResponse.json(plans);
} catch (error) {
console.error('GET /api/plans error:', error);
return NextResponse.json({ error: 'Erreur lors du chargement des plans' }, { status: 500 });
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id: ratingId } = await context.params;
const userId = req.headers.get('x-user-id');
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
}
try {
const { reply } = await req.json();
if (!reply || reply.trim() === '') {
return NextResponse.json({ error: 'La réponse ne peut pas être vide.' }, { status: 400 });
}
// 1. Fetch the rating to identify the business
const rating = await prisma.rating.findUnique({
where: { id: ratingId },
include: {
business: {
select: {
ownerId: true
}
}
}
});
if (!rating) {
return NextResponse.json({ error: 'Avis introuvable.' }, { status: 404 });
}
// 2. Security: Only the owner of the business can reply
if (rating.business.ownerId !== userId) {
return NextResponse.json({ error: 'Action non autorisée. Seul l\'entrepreneur concerné peut répondre.' }, { status: 403 });
}
// 3. Update the rating with the reply
const updatedRating = await prisma.rating.update({
where: { id: ratingId },
data: {
reply,
replyAt: new Date()
}
});
return NextResponse.json({
success: true,
reply: updatedRating.reply,
replyAt: updatedRating.replyAt
});
} catch (error: any) {
console.error('Error replying to rating:', error);
return NextResponse.json({ error: 'Erreur serveur lors de la réponse.' }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
import prisma from '@/lib/prisma'
// PATCH /api/users/me — Update personal user profile
export async function PATCH(

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import prisma from '@/lib/prisma';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import prisma from '@/lib/prisma'
// GET /api/users
export async function GET() {

View File

@@ -2,7 +2,6 @@ import React from 'react';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import { prisma } from '../../lib/prisma';
import { MOCK_BLOG_POSTS } from '../../lib/mockData';
export const revalidate = 3600; // Revalidate every hour
@@ -14,11 +13,8 @@ export default async function BlogPage() {
orderBy: { createdAt: 'desc' }
});
} catch (error) {
console.error("Database connection failed on blog page, using mock data.");
posts = MOCK_BLOG_POSTS.map(p => ({
...p,
createdAt: new Date(p.date), // Map mock date to createdAt to satisfy frontend
}));
console.error("Database connection failed on blog page.");
posts = [];
}
return (

View File

@@ -1,75 +1,36 @@
import React from 'react';
import { getLegalDocument } from '@/lib/legal';
export const metadata = {
title: 'Conditions Générales d\'Utilisation - Afropreunariat',
description: 'Consultez les conditions générales d\'utilisation de la plateforme Afropreunariat.',
title: 'Conditions Générales d\'Utilisation - Afrohub',
description: 'Consultez les conditions générales d\'utilisation de la plateforme Afrohub.',
};
export default function CGUPage() {
export default async function CGUPage() {
const doc = await getLegalDocument('CGU');
if (!doc) {
return (
<div className="bg-white min-h-screen py-16 px-4 flex items-center justify-center">
<p className="text-gray-500">Document non disponible.</p>
</div>
);
}
return (
<div className="bg-white min-h-screen py-16 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl font-serif font-bold text-gray-900 mb-8 border-b pb-4">
Conditions Générales d'Utilisation (CGU)
{doc.title}
</h1>
<div className="prose prose-orange max-w-none text-gray-600 space-y-6">
<p className="text-sm italic text-gray-500 mb-8">Dernière mise à jour : 12 avril 2026</p>
<div
className="prose prose-orange max-w-none text-gray-600 space-y-6"
dangerouslySetInnerHTML={{ __html: doc.content }}
/>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">1. Présentation du site</h2>
<p>
En vertu de l'article 6 de la loi n° 2004-575 du 21 juin 2004 pour la confiance dans l'économie numérique, il est précisé aux utilisateurs du site Afropreunariat l'identité des différents intervenants dans le cadre de sa réalisation et de son suivi.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">2. Conditions générales dutilisation du site et des services proposés</h2>
<p>
Lutilisation du site Afropreunariat implique lacceptation pleine et entière des conditions générales dutilisation ci-après décrites. Ces conditions dutilisation sont susceptibles dêtre modifiées ou complétées à tout moment, les utilisateurs du site sont donc invités à les consulter de manière régulière.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">3. Description des services fournis</h2>
<p>
Le site Afropreunariat a pour objet de fournir une plateforme de mise en relation entre entrepreneurs africains et clients, ainsi qu'un annuaire d'entreprises. Afropreunariat sefforce de fournir sur le site des informations aussi précises que possible. Toutefois, il ne pourra être tenue responsable des omissions, des inexactitudes et des carences dans la mise à jour, quelles soient de son fait ou du fait des tiers partenaires qui lui fournissent ces informations.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">4. Limitations contractuelles sur les données techniques</h2>
<p>
Le site utilise la technologie JavaScript (Next.js). Le site Internet ne pourra être tenu responsable de dommages matériels liés à lutilisation du site. De plus, lutilisateur du site sengage à accéder au site en utilisant un matériel récent, ne contenant pas de virus et avec un navigateur de dernière génération mis à jour.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">5. Propriété intellectuelle et contrefaçons</h2>
<p>
Afropreunariat est propriétaire des droits de propriété intellectuelle ou détient les droits dusage sur tous les éléments accessibles sur le site, notamment les textes, images, graphismes, logo, icônes, sons, logiciels. Toute reproduction, représentation, modification, publication, adaptation de tout ou partie des éléments du site, quel que soit le moyen ou le procédé utilisé, est interdite, sauf autorisation écrite préalable.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">6. Limitations de responsabilité</h2>
<p>
Afropreunariat ne pourra être tenue responsable des dommages directs et indirects causés au matériel de lutilisateur, lors de laccès au site Afropreunariat. Un espace interactif (possibilité de poser des questions dans lespace contact) est à la disposition des utilisateurs. Afropreunariat se réserve le droit de supprimer, sans mise en demeure préalable, tout contenu déposé dans cet espace qui contreviendrait à la législation applicable en France (incluant RGPD).
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">7. Gestion des données personnelles</h2>
<p>
En France, les données personnelles sont notamment protégées par la loi n° 78-87 du 6 janvier 1978, la loi n° 2004-801 du 6 août 2004, l'article L. 226-13 du Code pénal et le Règlement Européen sur la Protection des Données (RGPD). À l'occasion de l'utilisation du site, peuvent être recueillies : l'URL des liens par l'intermédiaire desquels l'utilisateur a accédé au site, le fournisseur d'accès de l'utilisateur, l'adresse de protocole Internet (IP) de l'utilisateur.
</p>
</section>
<div className="bg-gray-50 p-6 rounded-lg border border-gray-100 mt-12">
<p className="text-xs text-center text-gray-500 italic">
Note : Ce document est un modèle simplifié. Pour toute activité commerciale réelle, il est fortement recommandé de consulter un avocat pour adapter ce document à votre situation spécifique.
</p>
</div>
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">
Dernière mise à jour : {new Date(doc.updatedAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</div>
</div>
</div>

View File

@@ -1,75 +1,36 @@
import React from 'react';
import { getLegalDocument } from '@/lib/legal';
export const metadata = {
title: 'Conditions Générales de Vente - Afropreunariat',
description: 'Consultez les conditions générales de vente des services Afropreunariat.',
title: 'Conditions Générales de Vente - Afrohub',
description: 'Consultez les conditions générales de vente des services Afrohub.',
};
export default function CGVPage() {
export default async function CGVPage() {
const doc = await getLegalDocument('CGV');
if (!doc) {
return (
<div className="bg-white min-h-screen py-16 px-4 flex items-center justify-center">
<p className="text-gray-500">Document non disponible.</p>
</div>
);
}
return (
<div className="bg-white min-h-screen py-16 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl font-serif font-bold text-gray-900 mb-8 border-b pb-4">
Conditions Générales de Vente (CGV)
{doc.title}
</h1>
<div className="prose prose-orange max-w-none text-gray-600 space-y-6">
<p className="text-sm italic text-gray-500 mb-8">Dernière mise à jour : 12 avril 2026</p>
<div
className="prose prose-orange max-w-none text-gray-600 space-y-6"
dangerouslySetInnerHTML={{ __html: doc.content }}
/>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">1. Objet</h2>
<p>
Les présentes conditions générales de vente (CGV) régissent les relations contractuelles entre la plateforme Afropreunariat et tout utilisateur professionnel (l'Entrepreneur) souscrivant à des services payants sur le site.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">2. Description des services payants</h2>
<p>
Afropreunariat propose divers services payants destinés aux entrepreneurs, incluant mais ne se limitant pas à : l'inscription premium dans l'annuaire, la mise en avant de fiches entreprises, et des outils de communication avancés. Le contenu et les tarifs de ces services sont détaillés sur la page de tarification.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">3. Tarifs et paiement</h2>
<p>
Les prix de nos services sont indiqués en euros (ou monnaie locale) toutes taxes comprises. Afropreunariat se réserve le droit de modifier ses prix à tout moment, étant toutefois entendu que le prix figurant sur le site au jour de la commande sera le seul applicable à l'Entrepreneur. Le paiement est exigible immédiatement à la commande.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">4. Droit de rétractation</h2>
<p>
Conformément à l'article L221-28 du Code de la consommation, le droit de rétractation ne peut être exercé pour les services pleinement exécutés avant la fin du délai de rétractation ou dont l'exécution a commencé avec votre accord préalable exprès et renoncement exprès à votre droit de rétractation. Les abonnements numériques et services de mise en avant immédiate entrent dans ce cadre.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">5. Durée et résiliation</h2>
<p>
Les services de type "Abonnement" sont conclus pour la durée choisie lors de la souscription (mensuelle ou annuelle). Sauf mention contraire, ils sont reconduits tacitement. L'Entrepreneur peut résilier son abonnement à tout moment depuis son tableau de bord, la résiliation prenant effet à la fin de la période en cours.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">6. Responsabilité</h2>
<p>
La plateforme Afropreunariat n'est tenue que par une obligation de moyens. Elle ne saurait être tenue pour responsable des dommages résultant d'une mauvaise utilisation du service ou de l'absence de retours commerciaux suite à une mise en avant sur la plateforme.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">7. Règlement des litiges</h2>
<p>
Les présentes conditions de vente sont soumises à la loi française. En cas de litige, compétence est attribuée aux tribunaux compétents, nonobstant pluralité de défendeurs ou appel en garantie.
</p>
</section>
<div className="bg-gray-50 p-6 rounded-lg border border-gray-100 mt-12">
<p className="text-xs text-center text-gray-500 italic">
Note : Ce document est un modèle simplifié. Pour toute activité commerciale réelle, il est fortement recommandé de consulter un avocat pour adapter ce document à votre situation spécifique.
</p>
</div>
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">
Dernière mise à jour : {new Date(doc.updatedAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</div>
</div>
</div>

View File

@@ -6,7 +6,6 @@ import Link from 'next/link';
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye, MessageSquare, User as UserIcon, Rocket, ShieldAlert } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { User } from '../../types';
import { MOCK_BUSINESSES } from '../../lib/mockData';
import DashboardOverview from '../../components/dashboard/DashboardOverview';
import DashboardProfile from '../../components/dashboard/DashboardProfile';
import DashboardProfileClient from '../../components/dashboard/DashboardProfileClient';
@@ -23,7 +22,7 @@ const DashboardContent = () => {
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription' | 'messages' | 'personal-profile'>('overview');
const [business, setBusiness] = useState<any>(null);
const [stats, setStats] = useState<{ totalViews: number, contactClicks: number } | null>(null);
const [stats, setStats] = useState<{ totalViews: number, contactClicks: number, dailyStats: any[] } | null>(null);
const [loading, setLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
@@ -113,9 +112,9 @@ const DashboardContent = () => {
// Update document title with unread count
useEffect(() => {
if (unreadCount > 0) {
document.title = `(${unreadCount}) Messages - Afropreunariat`;
document.title = `(${unreadCount}) Messages - Afrohub`;
} else {
document.title = 'Tableau de Bord - Afropreunariat';
document.title = 'Tableau de Bord - Afrohub';
}
}, [unreadCount]);
@@ -153,6 +152,10 @@ const DashboardContent = () => {
slug: "",
tags: [],
socialLinks: {},
showEmail: true,
showPhone: true,
showSocials: true,
plan: 'STARTER',
verified: false,
viewCount: 0,
rating: 0,
@@ -166,7 +169,7 @@ const DashboardContent = () => {
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">A</div>
<span className="font-serif font-bold text-lg text-gray-900">Afropreunariat</span>
<span className="font-serif font-bold text-lg text-gray-900">Afrohub</span>
</Link>
</div>
<div className="flex-1 flex flex-col overflow-y-auto pt-5 pb-4">
@@ -297,7 +300,7 @@ const DashboardContent = () => {
{currentView === 'messages' && <DashboardMessages onMessagesRead={fetchUnreadCount} initialConversationId={chatId} />}
{currentView === 'personal-profile' && <DashboardProfileClient />}
{currentView === 'profile' && <DashboardProfile business={displayBusiness} setBusiness={setBusiness} />}
{currentView === 'offers' && <DashboardOffers businessId={displayBusiness.id} />}
{currentView === 'offers' && <DashboardOffers businessId={displayBusiness.id} plan={displayBusiness.plan} />}
{currentView === 'subscription' && (
<div className="space-y-6">
<div className="bg-white shadow rounded-lg p-6 flex justify-between items-center">

View File

@@ -10,9 +10,11 @@ import { Toaster } from 'react-hot-toast';
import { getSiteSettings } from '../lib/settings';
import './globals.css';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Afropreunariat - L\'Annuaire 2.0',
description: 'Annuaire Afropreunariat',
title: 'Afrohub - L\'Annuaire 2.0',
description: 'Annuaire Afrohub',
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {

View File

@@ -78,12 +78,16 @@ const HomePage = () => {
<h2 className="text-3xl font-bold text-gray-900 mb-8 font-serif">Secteurs en vedette</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{CATEGORIES.slice(0, 4).map((cat, idx) => (
<div key={idx} 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">
<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"
>
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mx-auto 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">{cat}</h3>
</div>
</Link>
))}
</div>
</div>

View File

@@ -193,7 +193,7 @@ const RegisterPage = () => {
</div>
<p className="mt-8 text-center text-xs text-gray-400 uppercase tracking-widest font-semibold">
Afropreunariat &copy; 2026
Afrohub &copy; 2026
</p>
</div>
</div>

View File

@@ -45,7 +45,7 @@ export default function SuspendedPage() {
<Mail className="w-5 h-5 text-indigo-600" />
<div>
<p className="text-xs font-bold text-indigo-900 uppercase">Support Direct</p>
<p className="text-sm text-indigo-700">moderation@afropreunariat.com</p>
<p className="text-sm text-indigo-700">moderation@afrohub.com</p>
</div>
</div>
</div>