feat: implement business detail client view with interactive features including rating, favoriting, messaging, and analytics tracking
This commit is contained in:
@@ -19,6 +19,7 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
||||
const router = useRouter();
|
||||
const { user, settings } = useUser();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [locationTerm, setLocationTerm] = useState('');
|
||||
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
|
||||
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
||||
const [categories, setCategories] = useState<any[]>(initialCategories);
|
||||
@@ -27,7 +28,10 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
router.push(`/annuaire?q=${searchTerm}`);
|
||||
const params = new URLSearchParams();
|
||||
if (searchTerm) params.append('q', searchTerm);
|
||||
if (locationTerm) params.append('location', locationTerm);
|
||||
router.push(`/annuaire?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -59,7 +63,13 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
||||
</div>
|
||||
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
|
||||
<MapPin className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
|
||||
<input type="text" placeholder="Localisation (ex: Abidjan)" className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Localisation (ex: Abidjan)"
|
||||
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
|
||||
value={locationTerm}
|
||||
onChange={(e) => setLocationTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
|
||||
Rechercher
|
||||
|
||||
28
app/actions/upload.ts
Normal file
28
app/actions/upload.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
'use server';
|
||||
|
||||
export async function uploadImage(formData: FormData) {
|
||||
try {
|
||||
const file = formData.get('file') as File;
|
||||
if (!file) {
|
||||
return { success: false, error: 'Aucun fichier fourni' };
|
||||
}
|
||||
|
||||
// Convert file to buffer
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Convert to Base64
|
||||
const base64 = buffer.toString('base64');
|
||||
const mimeType = file.type || 'image/jpeg';
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
// Return the data URL directly (it will be saved in the database)
|
||||
return {
|
||||
success: true,
|
||||
url: dataUrl
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error during image conversion:', error);
|
||||
return { success: false, error: 'Erreur lors de la conversion de l\'image' };
|
||||
}
|
||||
}
|
||||
@@ -439,9 +439,206 @@ const BusinessDetailClient = ({ initialBusiness }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avis et Commentaires */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||
<h2 className="text-xl font-bold font-serif text-gray-900 flex items-center">
|
||||
<Star className="w-5 h-5 mr-2 text-yellow-400 fill-current" /> Avis et Commentaires
|
||||
</h2>
|
||||
|
||||
{!isOwner && (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onMouseEnter={() => setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(null)}
|
||||
onClick={() => handleRate(star)}
|
||||
className="transition-transform active:scale-125 focus:outline-none"
|
||||
>
|
||||
<Star
|
||||
className={`w-8 h-8 ${star <= (hoverRating || userRating || 0) ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-medium italic">
|
||||
{userRating ? "Merci pour votre note !" : "Cliquez sur une étoile pour voter"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isOwner && (
|
||||
<div className="mb-10 bg-gray-50 rounded-xl p-5 border border-gray-100">
|
||||
<p className="text-sm font-bold text-gray-700 mb-3">Laissez un commentaire</p>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={reviewComment}
|
||||
onChange={(e) => setReviewComment(e.target.value)}
|
||||
placeholder="Que pensez-vous de cette entreprise ? (Qualité, service...)"
|
||||
className="w-full bg-white border border-gray-200 rounded-lg p-4 text-sm focus:ring-2 focus:ring-brand-500 outline-none h-24 resize-none shadow-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRate(userRating || 5)}
|
||||
disabled={isRating}
|
||||
className="absolute bottom-3 right-3 bg-brand-600 text-white px-4 py-1.5 rounded-md text-xs font-bold hover:bg-brand-700 transition-all flex items-center gap-2 shadow-md"
|
||||
>
|
||||
{isRating ? <Loader2 className="w-3 h-3 animate-spin" /> : "Publier"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{loadingRatings ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 className="w-8 h-8 text-brand-600 animate-spin" />
|
||||
</div>
|
||||
) : ratings.length > 0 ? (
|
||||
ratings.map((rating) => (
|
||||
<div key={rating.id} className="border-b border-gray-50 last:border-0 pb-6">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-brand-50 flex items-center justify-center text-brand-700 font-bold text-sm">
|
||||
{(rating as any).user?.name?.charAt(0) || 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-gray-900 text-sm">{(rating as any).user?.name}</p>
|
||||
<div className="flex items-center">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-3 h-3 ${star <= rating.value ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
|
||||
/>
|
||||
))}
|
||||
<span className="text-[10px] text-gray-400 ml-2 font-medium">
|
||||
{new Date(rating.createdAt).toLocaleDateString('fr-FR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed italic ml-13">
|
||||
<Quote className="w-3 h-3 text-brand-200 inline mr-1" />
|
||||
{rating.comment || "Pas de commentaire"}
|
||||
</p>
|
||||
|
||||
{/* Reply section for owner */}
|
||||
{isOwner && !rating.reply && (
|
||||
<div className="mt-4 ml-13">
|
||||
{replyingTo === rating.id ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Votre réponse..."
|
||||
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm outline-none focus:ring-1 focus:ring-brand-500 h-20"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleReply(rating.id)} disabled={isSubmittingReply} className="bg-brand-600 text-white px-3 py-1.5 rounded text-xs font-bold">Répondre</button>
|
||||
<button onClick={() => setReplyingTo(null)} className="text-gray-500 text-xs">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setReplyingTo(rating.id)} className="text-brand-600 text-xs font-bold hover:underline">Répondre au client</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rating.reply && (
|
||||
<div className="mt-4 ml-13 bg-brand-50/50 p-4 rounded-lg border-l-2 border-brand-300">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MessageCircle className="w-3 h-3 text-brand-600" />
|
||||
<span className="text-[10px] font-bold text-brand-700 uppercase">Réponse de {business.name}</span>
|
||||
</div>
|
||||
<p className="text-gray-700 text-sm italic">{rating.reply}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-10 bg-gray-50 rounded-xl border border-dashed border-gray-200">
|
||||
<Star className="w-10 h-10 text-gray-200 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">Soyez le premier à donner votre avis !</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Coordonnées</h2>
|
||||
<div className="space-y-4">
|
||||
{business.websiteUrl && (
|
||||
<a href={business.websiteUrl.startsWith('http') ? business.websiteUrl : `https://${business.websiteUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
|
||||
<Globe className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Site Web</p>
|
||||
<p className="text-sm font-medium truncate">{business.websiteUrl.replace(/^https?:\/\//, '')}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{business.showEmail && business.contactEmail && (
|
||||
<a href={`mailto:${business.contactEmail}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
|
||||
<Mail className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Email</p>
|
||||
<p className="text-sm font-medium truncate">{business.contactEmail}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{business.showPhone && business.contactPhone && (
|
||||
<a href={`tel:${business.contactPhone}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
|
||||
<Phone className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Téléphone</p>
|
||||
<p className="text-sm font-medium truncate">{business.contactPhone}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{business.showSocials && business.socialLinks && (
|
||||
<div className="pt-4 border-t border-gray-50">
|
||||
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-3">Réseaux Sociaux</p>
|
||||
<div className="flex gap-2">
|
||||
{business.socialLinks.facebook && (
|
||||
<a href={business.socialLinks.facebook} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-all">
|
||||
<Facebook className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{business.socialLinks.linkedin && (
|
||||
<a href={business.socialLinks.linkedin} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-700 hover:bg-blue-50 transition-all">
|
||||
<Linkedin className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{business.socialLinks.instagram && (
|
||||
<a href={business.socialLinks.instagram} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-pink-600 hover:bg-pink-50 transition-all">
|
||||
<Instagram className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{business.socialLinks.twitter && (
|
||||
<a href={business.socialLinks.twitter} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-black hover:bg-gray-100 transition-all">
|
||||
<Twitter className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="contact-form" ref={contactRef} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Contacter l'entreprise</h2>
|
||||
<form onSubmit={handleContactSubmit} className="space-y-4">
|
||||
|
||||
@@ -19,6 +19,7 @@ const AnnuairePageContent = () => {
|
||||
const [filterCategory, setFilterCategory] = useState('All');
|
||||
const [filterCountry, setFilterCountry] = useState('All');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [locationQuery, setLocationQuery] = useState('');
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// 1. Fetch Metadata (Countries & Categories)
|
||||
@@ -69,6 +70,7 @@ const AnnuairePageContent = () => {
|
||||
if (filterCategory !== 'All') params.append('category', filterCategory);
|
||||
if (filterCountry !== 'All') params.append('countryId', filterCountry);
|
||||
if (searchQuery) params.append('q', searchQuery);
|
||||
if (locationQuery) params.append('location', locationQuery);
|
||||
|
||||
const res = await fetch(`/api/businesses?${params.toString()}`, {
|
||||
headers: user ? { 'x-user-id': user.id } : {}
|
||||
@@ -86,12 +88,15 @@ const AnnuairePageContent = () => {
|
||||
|
||||
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [filterCategory, searchQuery, filterCountry]);
|
||||
}, [filterCategory, searchQuery, filterCountry, locationQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = searchParams.get('q');
|
||||
if (q) setSearchQuery(q);
|
||||
|
||||
const loc = searchParams.get('location');
|
||||
if (loc) setLocationQuery(loc);
|
||||
|
||||
const cat = searchParams.get('category');
|
||||
if (cat) setFilterCategory(cat);
|
||||
|
||||
@@ -124,6 +129,17 @@ const AnnuairePageContent = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Ville / Localisation</label>
|
||||
<input
|
||||
type="text"
|
||||
value={locationQuery}
|
||||
onChange={(e) => setLocationQuery(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"
|
||||
placeholder="Ex: Abidjan, Dakar..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Pays</label>
|
||||
<select
|
||||
|
||||
@@ -8,6 +8,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const category = searchParams.get('category')
|
||||
const q = searchParams.get('q')?.toLowerCase()
|
||||
const location = searchParams.get('location')?.toLowerCase()
|
||||
const featured = searchParams.get('featured')
|
||||
const countryId = searchParams.get('countryId')
|
||||
|
||||
@@ -19,11 +20,12 @@ export async function GET(request: NextRequest) {
|
||||
isSuspended: false
|
||||
}
|
||||
}
|
||||
|
||||
// Build the conditions array
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (category && category !== 'All') {
|
||||
// Robust check: IDs are usually alphanumeric and long, without spaces or special chars
|
||||
// Names like "Agriculture & Agrobusiness" have spaces or special chars.
|
||||
const isId = /^[a-z0-9-]+$/.test(category) && category.length > 20;
|
||||
|
||||
if (isId) {
|
||||
where.categoryId = category
|
||||
} else {
|
||||
@@ -32,13 +34,30 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
if (featured === 'true') where.isFeatured = true
|
||||
if (countryId) where.countryId = countryId
|
||||
|
||||
if (q) {
|
||||
where.OR = [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ description: { contains: q, mode: 'insensitive' } },
|
||||
// Simplified tags check
|
||||
{ tags: { has: q } }
|
||||
]
|
||||
conditions.push({
|
||||
OR: [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ description: { contains: q, mode: 'insensitive' } },
|
||||
{ city: { contains: q, mode: 'insensitive' } },
|
||||
{ location: { contains: q, mode: 'insensitive' } },
|
||||
{ tags: { has: q } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (location) {
|
||||
conditions.push({
|
||||
OR: [
|
||||
{ city: { contains: location, mode: 'insensitive' } },
|
||||
{ location: { contains: location, mode: 'insensitive' } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
where.AND = conditions;
|
||||
}
|
||||
|
||||
const dbBusinesses = await prisma.business.findMany({
|
||||
|
||||
@@ -279,10 +279,12 @@ const DashboardContent = () => {
|
||||
{currentView === 'subscription' && 'Mon Abonnement'}
|
||||
</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href={displayBusiness.id === 'new' ? '/annuaire' : `/annuaire/${displayBusiness.slug || displayBusiness.id}`} className="hidden sm: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 focus:outline-none">
|
||||
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
|
||||
Voir ma fiche
|
||||
</Link>
|
||||
{business?.isActive && (
|
||||
<Link href={`/annuaire/${business.slug || business.id}`} className="hidden sm: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 focus:outline-none transition-all hover:scale-105 active:scale-95">
|
||||
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
|
||||
Voir ma fiche
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -27,9 +27,10 @@ body {
|
||||
|
||||
.prose {
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
hyphens: none;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
|
||||
@@ -39,11 +39,25 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const settings = await getSiteSettings();
|
||||
const isDev = process.env.NEXT_PUBLIC_APP_ENV === 'development';
|
||||
|
||||
return (
|
||||
<html lang="fr" className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
{isDev && (
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
:root {
|
||||
--color-brand-50: #fef2f2 !important;
|
||||
--color-brand-100: #fee2e2 !important;
|
||||
--color-brand-200: #fecaca !important;
|
||||
--color-brand-500: #ef4444 !important;
|
||||
--color-brand-600: #dc2626 !important;
|
||||
--color-brand-700: #b91c1c !important;
|
||||
--color-brand-900: #7f1d1d !important;
|
||||
}
|
||||
`}} />
|
||||
)}
|
||||
</head>
|
||||
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans" suppressHydrationWarning>
|
||||
<UserProvider>
|
||||
|
||||
Reference in New Issue
Block a user