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
Some checks failed
Build and Push App / build (push) Failing after 16m2s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user