synchronisation du contenu avec la DB et amélioration du CMS

- Migration du blog et des interviews des données mockées vers PostgreSQL (Prisma).
- Refonte des pages publiques en Server Components pour de meilleures performances/SEO.
- Intégration d'un éditeur de texte riche (React Quill) avec titres H1-H6 et séparateurs.
- Correction des erreurs de validation Prisma liées aux paramètres asynchrones (Next.js 15+).
- Correction des problèmes d'affichage des modales de suspension via React Portals.
- Installation de @tailwindcss/typography et correction du débordement de texte (break-words).
- Implémentation des actions de modération (suspension/révocation) pour les utilisateurs et boutiques.
This commit is contained in:
2026-04-12 18:59:20 +02:00
parent b73939d381
commit 887030ee47
84 changed files with 5577 additions and 15128 deletions

View File

@@ -1,22 +1,24 @@
"use client";
import React, { useEffect } from 'react';
import React from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { notFound } from 'next/navigation';
import { ArrowLeft, Share2, Play, Calendar, User, Building2 } from 'lucide-react';
import { MOCK_INTERVIEWS } from '../../../lib/mockData';
import { InterviewType } from '../../../types';
import { prisma } from '../../../lib/prisma';
import { InterviewType } from '@prisma/client';
const InterviewDetailPage = () => {
const { id } = useParams();
const interview = MOCK_INTERVIEWS.find(i => i.id === id);
interface Props {
params: Promise<{ id: string }>;
}
useEffect(() => {
window.scrollTo(0, 0);
}, [id]);
export default async function InterviewDetailPage({ params }: Props) {
const { id } = await params;
const interview = await prisma.interview.findUnique({
where: { id }
});
if (!interview) return null;
if (!interview) {
notFound();
}
// Helper to get YouTube embed URL
const getEmbedUrl = (url: string) => {
@@ -56,7 +58,7 @@ const InterviewDetailPage = () => {
</div>
<div className="flex items-center">
<Calendar className="w-4 h-4 mr-2 text-brand-500" />
{interview.date}
{new Date(interview.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</div>
</div>
</div>
@@ -73,7 +75,7 @@ const InterviewDetailPage = () => {
className="w-full h-full"
></iframe>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-900">
<div className="w-full h-full flex items-center justify-center bg-gray-900 min-h-[300px]">
<p className="text-white">Vidéo non disponible</p>
</div>
)}
@@ -86,11 +88,9 @@ const InterviewDetailPage = () => {
)}
{/* Description / Article Content */}
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600">
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600 break-words overflow-hidden">
{!isVideo && interview.content ? (
interview.content.split('\n').map((paragraph, idx) => (
<p key={idx}>{paragraph}</p>
))
<div dangerouslySetInnerHTML={{ __html: interview.content }} />
) : (
<p className="text-xl font-serif italic text-gray-500 border-l-4 border-brand-500 pl-6 py-2">
{interview.excerpt}
@@ -108,6 +108,5 @@ const InterviewDetailPage = () => {
</div>
</div>
);
};
}
export default InterviewDetailPage;

View File

@@ -1,18 +1,27 @@
"use client";
import React, { useState } from 'react';
import React from 'react';
import Link from 'next/link';
import { Play, FileText, Clock, Mic } from 'lucide-react';
import { MOCK_INTERVIEWS } from '../../lib/mockData';
import { InterviewType } from '../../types';
import { prisma } from '../../lib/prisma';
import { InterviewType } from '@prisma/client';
const AfroLifePage = () => {
const [filter, setFilter] = useState<'ALL' | 'VIDEO' | 'ARTICLE'>('ALL');
export const revalidate = 3600;
const filteredInterviews = MOCK_INTERVIEWS.filter(interview => {
if (filter === 'ALL') return true;
return interview.type === filter;
interface Props {
searchParams: Promise<{ type?: string }>;
}
export default async function AfroLifePage({ searchParams }: Props) {
const params = await searchParams;
const filter = params.type as InterviewType | 'ALL' || 'ALL';
const where: any = {};
if (filter !== 'ALL') {
where.type = filter;
}
const interviews = await prisma.interview.findMany({
where,
orderBy: { createdAt: 'desc' }
});
return (
@@ -34,77 +43,81 @@ const AfroLifePage = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Filters */}
<div className="flex justify-center mb-12 space-x-4">
<button
onClick={() => setFilter('ALL')}
<Link
href="/afrolife?type=ALL"
className={`px-6 py-2 rounded-full text-sm font-medium transition-all ${filter === 'ALL' ? 'bg-brand-600 text-white shadow-md' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
Tout voir
</button>
<button
onClick={() => setFilter('VIDEO')}
</Link>
<Link
href="/afrolife?type=VIDEO"
className={`flex items-center px-6 py-2 rounded-full text-sm font-medium transition-all ${filter === 'VIDEO' ? 'bg-brand-600 text-white shadow-md' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
<Play className="w-4 h-4 mr-2" /> Vidéos
</button>
<button
onClick={() => setFilter('ARTICLE')}
</Link>
<Link
href="/afrolife?type=ARTICLE"
className={`flex items-center px-6 py-2 rounded-full text-sm font-medium transition-all ${filter === 'ARTICLE' ? 'bg-brand-600 text-white shadow-md' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
<FileText className="w-4 h-4 mr-2" /> Articles
</button>
</Link>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredInterviews.map(interview => (
<Link href={`/afrolife/${interview.id}`} key={interview.id} className="group block h-full">
<div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300">
<img
src={interview.thumbnailUrl}
alt={interview.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
{/* Type Badge */}
<div className="absolute top-4 left-4">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${interview.type === InterviewType.VIDEO ? 'bg-red-600/90' : 'bg-blue-600/90'}`}>
{interview.type === InterviewType.VIDEO ? <Play className="w-3 h-3 mr-1 fill-current" /> : <Mic className="w-3 h-3 mr-1" />}
{interview.type === InterviewType.VIDEO ? 'Vidéo' : 'Interview'}
</span>
</div>
{/* Play Icon Overlay for Videos */}
{interview.type === InterviewType.VIDEO && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50">
<Play className="w-8 h-8 text-white fill-current ml-1" />
</div>
</div>
)}
</div>
<div className="pt-6 px-2">
<div className="flex items-center text-xs text-gray-500 mb-3 space-x-3">
<span className="font-semibold text-brand-600 uppercase">{interview.guestName}</span>
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
<span>{interview.companyName}</span>
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
<span className="flex items-center"><Clock className="w-3 h-3 mr-1"/> {interview.duration}</span>
</div>
<h3 className="text-xl font-serif font-bold text-gray-900 mb-2 group-hover:text-brand-600 transition-colors leading-tight">
{interview.title}
</h3>
<p className="text-gray-600 text-sm line-clamp-2">
{interview.excerpt}
</p>
</div>
</Link>
))}
</div>
{interviews.length === 0 ? (
<div className="text-center py-20 bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200">
<p className="text-gray-500">Aucune interview trouvée dans cette catégorie.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{interviews.map(interview => (
<Link href={`/afrolife/${interview.id}`} key={interview.id} className="group block h-full">
<div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300">
<img
src={interview.thumbnailUrl}
alt={interview.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
{/* Type Badge */}
<div className="absolute top-4 left-4">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${interview.type === InterviewType.VIDEO ? 'bg-red-600/90' : 'bg-blue-600/90'}`}>
{interview.type === InterviewType.VIDEO ? <Play className="w-3 h-3 mr-1 fill-current" /> : <Mic className="w-3 h-3 mr-1" />}
{interview.type === InterviewType.VIDEO ? 'Vidéo' : 'Interview'}
</span>
</div>
{/* Play Icon Overlay for Videos */}
{interview.type === InterviewType.VIDEO && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50">
<Play className="w-8 h-8 text-white fill-current ml-1" />
</div>
</div>
)}
</div>
<div className="pt-6 px-2">
<div className="flex items-center text-xs text-gray-500 mb-3 space-x-3">
<span className="font-semibold text-brand-600 uppercase">{interview.guestName}</span>
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
<span>{interview.companyName}</span>
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
<span className="flex items-center"><Clock className="w-3 h-3 mr-1"/> {interview.duration || 'N/A'}</span>
</div>
<h3 className="text-xl font-serif font-bold text-gray-900 mb-2 group-hover:text-brand-600 transition-colors leading-tight line-clamp-2">
{interview.title}
</h3>
<p className="text-gray-600 text-sm line-clamp-2">
{interview.excerpt}
</p>
</div>
</Link>
))}
</div>
)}
</div>
</div>
);
};
export default AfroLifePage;
}

View File

@@ -3,16 +3,20 @@
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Play, Share2, Send, Award, Quote } from 'lucide-react';
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 { toast } from 'react-hot-toast';
import { useUser } from '../../../components/UserProvider';
const BusinessDetailPage = () => {
const { id } = useParams();
const router = useRouter();
const { user } = useUser();
const [business, setBusiness] = useState<Business | null>(null);
const [loading, setLoading] = useState(true);
const [existingConversationId, setExistingConversationId] = useState<string | null>(null);
const [contactForm, setContactForm] = useState({
name: '',
@@ -50,7 +54,28 @@ const BusinessDetailPage = () => {
const data = await res.json();
setBusiness(data);
// 3. Increment views silently
// 3. Track view event
// Only track if not owner
const currentUserStr = localStorage.getItem('afro_user');
const currentUser = currentUserStr ? JSON.parse(currentUserStr) : null;
if (!currentUser || currentUser.id !== data.ownerId) {
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'BUSINESS_VIEW',
path: window.location.pathname,
label: data.name,
metadata: {
businessId: data.id,
businessName: data.name,
userId: currentUser?.id || 'anonymous'
}
})
}).catch(console.error);
}
// Also increment views count in DB (Legacy support)
fetch(`/api/businesses/${id}/view`, { method: 'POST' }).catch(console.error);
} else {
setBusiness(null);
@@ -66,6 +91,26 @@ const BusinessDetailPage = () => {
if (id) loadBusiness();
}, [id]);
useEffect(() => {
const checkExistingConversation = async () => {
if (!user || user.id === 'undefined' || !business?.id) return;
try {
const res = await fetch(`/api/conversations?businessId=${business.id}`, {
headers: { 'x-user-id': user.id }
});
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
setExistingConversationId(data[0].id);
}
} catch (error) {
console.error('Error checking existing conversation:', error);
}
};
if (user && business) checkExistingConversation();
}, [user, business]);
if (loading) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center">
@@ -95,18 +140,77 @@ const BusinessDetailPage = () => {
const videoEmbedUrl = business.videoUrl ? getEmbedUrl(business.videoUrl) : null;
const handleContactSubmit = (e: React.FormEvent) => {
const handleTrackEvent = async (type: string, label?: string) => {
if (!business?.id) return;
// Don't track if the current user is the owner
if (user && user.id === business.ownerId) return;
try {
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
path: window.location.pathname,
label: label || business.name,
metadata: {
businessId: business.id,
businessName: business.name,
userId: user?.id || 'anonymous'
}
})
});
} catch (e) {
console.error('Analytics error:', e);
}
};
const handleContactSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) {
toast.error('Veuillez vous connecter pour envoyer un message');
router.push('/login');
return;
}
setFormStatus('sending');
// Simulate API call
setTimeout(() => {
setFormStatus('success');
setContactForm({ name: '', email: '', message: '' });
try {
const res = await fetch('/api/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user.id
},
body: JSON.stringify({
businessId: business.id,
content: contactForm.message
})
});
// Reset status after 3 seconds
setTimeout(() => setFormStatus('idle'), 3000);
}, 1500);
const data = await res.json();
if (!data.error) {
// Track contact event
handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Message Sent');
setFormStatus('success');
toast.success('Message envoyé ! Redirection vers votre messagerie...');
setContactForm({ ...contactForm, message: '' });
setTimeout(() => {
router.push('/dashboard?view=messages'); // Hypothetical view param handle later
}, 2000);
} else {
toast.error(data.error);
setFormStatus('idle');
}
} catch (error) {
toast.error("Erreur lors de l'envoi");
setFormStatus('idle');
}
};
return (
@@ -276,24 +380,45 @@ const BusinessDetailPage = () => {
<div 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>
{/* Direct Info */}
<div className="space-y-4 mb-6">
<a href={`mailto:${business.contactEmail}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors">
<div className="flex items-center text-gray-600">
<div className="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center mr-3 text-brand-600 flex-shrink-0">
<Mail className="w-4 h-4" />
<MapPin className="w-4 h-4" />
</div>
<span className="text-sm truncate">{business.contactEmail}</span>
</a>
{business.contactPhone && (
<a href={`tel:${business.contactPhone}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors">
<span className="text-sm">{business.location}</span>
</div>
{business.showEmail && (
<a
href={`mailto:${business.contactEmail}`}
onClick={() => handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Email')}
className="flex items-center text-gray-600 hover:text-brand-600 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center mr-3 text-brand-600 flex-shrink-0">
<Mail className="w-4 h-4" />
</div>
<span className="text-sm truncate">{business.contactEmail}</span>
</a>
)}
{(business.contactPhone && business.showPhone) && (
<a
href={`tel:${business.contactPhone}`}
onClick={() => handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Phone')}
className="flex items-center text-gray-600 hover:text-brand-600 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center mr-3 text-brand-600 flex-shrink-0">
<Phone className="w-4 h-4" />
</div>
<span className="text-sm">{business.contactPhone}</span>
</a>
)}
{business.socialLinks?.website && (
<a href={business.socialLinks.website} target="_blank" rel="noopener noreferrer" className="flex items-center text-gray-600 hover:text-brand-600 transition-colors">
{(business.websiteUrl || business.socialLinks?.website) && (
<a
href={business.websiteUrl || business.socialLinks?.website}
target="_blank"
rel="noopener noreferrer"
onClick={() => handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Website')}
className="flex items-center text-gray-600 hover:text-brand-600 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center mr-3 text-brand-600 flex-shrink-0">
<Globe className="w-4 h-4" />
</div>
@@ -302,72 +427,95 @@ const BusinessDetailPage = () => {
)}
</div>
{/* Contact Form */}
{/* Contact Section */}
<div className="border-t border-gray-100 pt-6">
<h4 className="text-sm font-semibold text-gray-900 mb-3">Envoyer un message</h4>
{formStatus === 'success' ? (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md text-sm">
Message envoyé avec succès !
<h4 className="text-sm font-semibold text-gray-900 mb-4 flex items-center gap-2">
<MessageCircle className="w-4 h-4 text-brand-600" />
Discuter avec l'entrepreneur
</h4>
{existingConversationId ? (
<div className="space-y-4">
<p className="text-sm text-gray-500 text-center bg-brand-50 p-4 rounded-xl border border-brand-100">
Vous avez déjà une discussion en cours avec cet entrepreneur.
</p>
<Link
href={`/dashboard?view=messages&chatId=${existingConversationId}`}
className="w-full flex justify-center items-center gap-2 px-4 py-3 border border-transparent text-sm font-bold rounded-xl text-white bg-brand-600 hover:bg-brand-700 shadow-lg transition-all transform active:scale-[0.98]"
>
<MessageCircle className="w-5 h-5" />
Continuer la discussion
</Link>
</div>
) : formStatus === 'success' ? (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-8 rounded-xl text-center">
<CheckCircle className="w-10 h-10 mx-auto mb-2 text-green-500" />
<h4 className="font-bold">Message envoyé !</h4>
<p className="text-sm">Redirection vers votre messagerie...</p>
</div>
) : (
<form onSubmit={handleContactSubmit} className="space-y-3">
<div>
<input
type="text"
placeholder="Votre nom"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-brand-500"
value={contactForm.name}
onChange={(e) => setContactForm({ ...contactForm, name: e.target.value })}
/>
</div>
<div>
<input
type="email"
placeholder="Votre email"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-brand-500"
value={contactForm.email}
onChange={(e) => setContactForm({ ...contactForm, email: e.target.value })}
/>
</div>
<div>
<form onSubmit={handleContactSubmit} className="space-y-4">
<div className="relative">
<textarea
placeholder="Votre message..."
placeholder={user ? "Bonjour, j'aimerais en savoir plus sur..." : "Connectez-vous pour envoyer un message"}
required
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-brand-500 resize-none"
rows={4}
disabled={!user || formStatus === 'sending'}
className="w-full px-4 py-3 bg-gray-50 border border-transparent rounded-xl text-sm focus:bg-white focus:ring-2 focus:ring-brand-500 outline-none transition-all resize-none disabled:opacity-60 shadow-inner"
value={contactForm.message}
onChange={(e) => setContactForm({ ...contactForm, message: e.target.value })}
/>
{!user && (
<div className="absolute inset-0 bg-white/20 backdrop-blur-[1px] rounded-xl flex items-center justify-center">
<Lock className="w-5 h-5 text-gray-400" />
</div>
)}
</div>
<button
type="submit"
disabled={formStatus === 'sending'}
className="w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gray-900 hover:bg-gray-800 focus:outline-none transition-colors disabled:opacity-70"
disabled={!user || formStatus === 'sending' || !contactForm.message.trim()}
className="w-full flex justify-center items-center gap-2 px-4 py-3 border border-transparent text-sm font-bold rounded-xl text-white bg-brand-600 hover:bg-brand-700 shadow-lg transition-all disabled:opacity-50 disabled:grayscale transform active:scale-[0.98]"
>
{formStatus === 'sending' ? 'Envoi...' : <><Send className="w-3 h-3 mr-2" /> Envoyer</>}
{formStatus === 'sending' ? (
<Loader2 className="w-5 h-5 animate-spin text-white" />
) : (
<>
{user ? <Send className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
{user ? "Lancer la discussion" : "Action réservée aux membres"}
</>
)}
</button>
{!user && (
<p className="text-[10px] text-center text-gray-400">
Inscrivez-vous gratuitement pour contacter les entrepreneurs.
</p>
)}
</form>
)}
</div>
<div className="mt-6 pt-6 border-t border-gray-100">
<div className="flex justify-center space-x-6">
{business.socialLinks?.facebook && (
<a href={business.socialLinks.facebook} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-[#1877F2] transition-colors">
<Facebook className="w-5 h-5" />
</a>
)}
{business.socialLinks?.linkedin && (
<a href={business.socialLinks.linkedin} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-[#0A66C2] transition-colors">
<Linkedin className="w-5 h-5" />
</a>
)}
{business.socialLinks?.instagram && (
<a href={business.socialLinks.instagram} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-[#E4405F] transition-colors">
<Instagram className="w-5 h-5" />
</a>
{business.showSocials && (
<>
{business.socialLinks?.facebook && (
<a href={business.socialLinks.facebook} target="_blank" rel="noopener noreferrer" onClick={() => handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Facebook')} className="text-gray-400 hover:text-[#1877F2] transition-colors">
<Facebook className="w-5 h-5" />
</a>
)}
{business.socialLinks?.linkedin && (
<a href={business.socialLinks.linkedin} target="_blank" rel="noopener noreferrer" onClick={() => handleTrackEvent('BUSINESS_CONTACT_CLICK', 'LinkedIn')} className="text-gray-400 hover:text-[#0A66C2] transition-colors">
<Linkedin className="w-5 h-5" />
</a>
)}
{business.socialLinks?.instagram && (
<a href={business.socialLinks.instagram} target="_blank" rel="noopener noreferrer" onClick={() => handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Instagram')} className="text-gray-400 hover:text-[#E4405F] transition-colors">
<Instagram className="w-5 h-5" />
</a>
)}
</>
)}
</div>
</div>

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../../lib/prisma'
// PATCH /api/admin/reports/[id] — Update report status
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const userId = request.headers.get('x-user-id')
const { id } = await params
const { status } = await request.json()
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
// Check if user is admin
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true }
})
if (!user || user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
}
const report = await prisma.messageReport.update({
where: { id },
data: { status }
})
return NextResponse.json(report)
} catch (error) {
console.error('PATCH /api/admin/reports/[id] error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
// GET /api/admin/reports — List all reports for moderation
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
// Check if user is admin
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true }
})
if (!user || user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
}
const reports = await prisma.messageReport.findMany({
include: {
message: {
include: {
sender: {
select: { id: true, name: true, email: true }
},
conversation: {
include: {
business: {
select: { id: true, name: true }
}
}
}
}
},
reporter: {
select: { id: true, name: true, email: true }
}
},
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json(reports)
} catch (error) {
console.error('GET /api/admin/reports error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
if (!id) {
return NextResponse.json({ error: 'ID Business requis' }, { status: 400 });
}
// 1. Get total views from AnalyticsEvent
const totalViews = await prisma.analyticsEvent.count({
where: {
type: 'BUSINESS_VIEW',
metadata: {
path: ['businessId'],
equals: id
}
}
});
// 2. Get contact clicks from AnalyticsEvent
const contactClicks = await prisma.analyticsEvent.count({
where: {
type: 'BUSINESS_CONTACT_CLICK',
metadata: {
path: ['businessId'],
equals: id
}
}
});
// 3. Get monthly stats (views per day for last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Note: Raw query might be needed for group by date in some DBs,
// but for now we'll just return the totals for simplicity.
return NextResponse.json({
businessId: id,
totalViews,
contactClicks,
updatedAt: new Date().toISOString()
});
} catch (error) {
console.error('Error fetching business analytics:', error);
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}

View File

@@ -11,7 +11,13 @@ export async function GET(request: NextRequest) {
const featured = searchParams.get('featured')
// 1. Fetch from Database
const where: any = {}
const where: any = {
isActive: true,
isSuspended: false,
owner: {
isSuspended: false
}
}
if (category && category !== 'All') where.category = category
if (featured === 'true') where.isFeatured = true
if (q) {
@@ -72,19 +78,22 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'ownerId est requis (via body ou x-user-id header)' }, { status: 400 })
}
// 1. Sanitize data - ONLY pass fields that exist in the Prisma Business model
// We must exclude 'id', 'offers', 'owner', 'createdAt', 'updatedAt' from the data object
// 1. Sanitize & Validate data
const cleanData: any = {
name: data.name || "Nouvelle Entreprise",
name: data.name || "",
slug: data.slug || null,
category: data.category || "Autre",
location: data.location || "Ma Ville",
location: data.location || "",
description: data.description || "",
logoUrl: data.logoUrl || "https://picsum.photos/200/200?random=default",
videoUrl: data.videoUrl || null,
contactEmail: data.contactEmail || "",
contactPhone: data.contactPhone || null,
websiteUrl: data.websiteUrl || null,
socialLinks: data.socialLinks || {},
showEmail: data.showEmail ?? true,
showPhone: data.showPhone ?? true,
showSocials: data.showSocials ?? true,
verified: data.verified ?? false,
viewCount: data.viewCount ?? 0,
rating: data.rating ?? 0,
@@ -94,6 +103,15 @@ export async function POST(request: NextRequest) {
founderImageUrl: data.founderImageUrl || null,
keyMetric: data.keyMetric || null,
}
// 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 isEmailOk = !!cleanData.contactEmail;
const isPhoneOk = !!cleanData.contactPhone;
cleanData.isActive = !!(isNameOk && isDescOk && isLocOk && isEmailOk && isPhoneOk);
// 2. Slug Uniqueness Check
if (cleanData.slug) {
@@ -133,6 +151,12 @@ export async function POST(request: NextRequest) {
},
include: { owner: true }
});
// Automatic role upgrade
await prisma.user.update({
where: { id: ownerId },
data: { role: 'ENTREPRENEUR' }
});
}
console.log('Business saved successfully:', business.id);

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../../lib/prisma'
// PATCH /api/conversations/[id]/archive — Toggle archive status for the user
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const userId = request.headers.get('x-user-id')
const { id } = await params
const { archived } = await request.json()
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const updatedParticipant = await prisma.conversationParticipant.update({
where: {
userId_conversationId: {
userId: userId,
conversationId: id
}
},
data: {
isArchived: archived ?? true
}
})
return NextResponse.json(updatedParticipant)
} catch (error) {
console.error('PATCH /api/conversations/[id]/archive error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
// GET /api/conversations/[id] — Get messages for a conversation
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const userId = request.headers.get('x-user-id')
const { id } = await params
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
// Verify user is a participant
const isParticipant = await prisma.conversationParticipant.findUnique({
where: {
userId_conversationId: {
userId: userId,
conversationId: id
}
}
})
if (!isParticipant) {
return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 })
}
const messages = await prisma.message.findMany({
where: { conversationId: id },
orderBy: { createdAt: 'asc' },
include: {
sender: {
select: {
id: true,
name: true,
avatar: true
}
}
}
})
// Mark messages as read (where sender is not the current user)
await prisma.message.updateMany({
where: {
conversationId: id,
senderId: { not: userId },
read: false
},
data: { read: true }
})
return NextResponse.json(messages)
} catch (error) {
console.error('GET /api/conversations/[id] error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
// GET /api/conversations — List all conversations for the user
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const businessId = searchParams.get('businessId')
const conversations = await prisma.conversation.findMany({
where: {
participants: {
some: { userId: userId }
},
...(businessId ? { businessId: businessId } : {})
},
include: {
business: {
select: {
id: true,
name: true,
logoUrl: true,
slug: true,
ownerId: true
}
},
participants: {
include: {
user: {
select: {
id: true,
name: true,
avatar: true,
role: true
}
}
}
},
messages: {
orderBy: { createdAt: 'desc' },
take: 1
}
},
orderBy: {
updatedAt: 'desc'
}
})
return NextResponse.json(conversations)
} catch (error) {
console.error('GET /api/conversations error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../../lib/prisma'
// POST /api/messages/[id]/report — Report a message for moderation
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const userId = request.headers.get('x-user-id')
const { id } = await params
const { reason } = await request.json()
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
// Verify message exists
const message = await prisma.message.findUnique({
where: { id }
})
if (!message) {
return NextResponse.json({ error: 'Message introuvable' }, { status: 404 })
}
// Check if user already reported this message
const existingReport = await prisma.messageReport.findFirst({
where: {
messageId: id,
reporterId: userId
}
})
if (existingReport) {
return NextResponse.json({ error: 'Vous avez déjà signalé ce message' }, { status: 400 })
}
const report = await prisma.messageReport.create({
data: {
messageId: id,
reporterId: userId,
reason: reason || 'Non spécifié'
}
})
return NextResponse.json(report)
} catch (error) {
console.error('POST /api/messages/[id]/report error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

115
app/api/messages/route.ts Normal file
View File

@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
// POST /api/messages — Send a message or start a conversation
export async function POST(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
const { businessId, conversationId, content } = await request.json()
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
if (!content) {
return NextResponse.json({ error: 'Contenu manquant' }, { status: 400 })
}
let conversation;
let receiverId;
let finalBusinessId = businessId;
if (conversationId) {
// Case 1: Reply to an existing conversation
conversation = await prisma.conversation.findUnique({
where: { id: conversationId },
include: { participants: true }
});
if (!conversation) {
return NextResponse.json({ error: 'Conversation introuvable' }, { status: 404 });
}
const isParticipant = conversation.participants.some(p => p.userId === userId);
if (!isParticipant) {
return NextResponse.json({ error: 'Vous n\'êtes pas participant à cette conversation' }, { status: 403 });
}
// Find the other participant to determine receiverId
const otherParticipant = conversation.participants.find(p => p.userId !== userId);
receiverId = otherParticipant?.userId;
finalBusinessId = conversation.businessId;
} else if (businessId) {
// Case 2: Start a new conversation via business profile
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { ownerId: true }
})
if (!business) {
return NextResponse.json({ error: 'Entreprise introuvable' }, { status: 404 })
}
receiverId = business.ownerId
if (userId === receiverId) {
return NextResponse.json({ error: 'Vous ne pouvez pas vous envoyer un message à vous-même' }, { status: 400 })
}
// Find or create a conversation for this user + business
conversation = await prisma.conversation.findFirst({
where: {
businessId: businessId,
participants: {
every: {
userId: { in: [userId, receiverId] }
}
}
}
})
if (!conversation) {
conversation = await prisma.conversation.create({
data: {
businessId: businessId,
participants: {
create: [
{ userId: userId },
{ userId: receiverId }
]
}
}
})
}
} else {
return NextResponse.json({ error: 'ID Business ou ID Conversation manquant' }, { status: 400 })
}
// 3. Create the message
const message = await prisma.message.create({
data: {
content,
senderId: userId,
conversationId: conversation.id
}
})
// 4. Update conversation updatedAt timestamp AND reset archive status for all
await prisma.$transaction([
prisma.conversation.update({
where: { id: conversation.id },
data: { updatedAt: new Date() }
}),
prisma.conversationParticipant.updateMany({
where: { conversationId: conversation.id },
data: { isArchived: false }
})
])
return NextResponse.json(message)
} catch (error) {
console.error('POST /api/messages error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const unreadCount = await prisma.message.count({
where: {
conversation: {
participants: {
some: { userId: userId }
}
},
senderId: { not: userId },
read: false
}
})
return NextResponse.json({ count: unreadCount })
} catch (error) {
console.error('GET /api/messages/unread error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

39
app/api/users/me/route.ts Normal file
View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
// PATCH /api/users/me — Update personal user profile
export async function PATCH(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
const data = await request.json()
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const updatedUser = await prisma.user.update({
where: { id: userId },
data: {
name: data.name,
phone: data.phone,
bio: data.bio,
location: data.location,
},
select: {
id: true,
name: true,
email: true,
role: true,
phone: true,
bio: true,
location: true,
avatar: true
}
})
return NextResponse.json(updatedUser)
} catch (error) {
console.error('PATCH /api/users/me error:', error)
return NextResponse.json({ error: 'Erreur lors de la mise à jour' }, { status: 500 })
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const email = searchParams.get('email');
if (!email) {
return NextResponse.json({ error: 'Email requis' }, { status: 400 });
}
try {
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
name: true,
email: true,
role: true,
avatar: true
}
});
if (!user) {
return NextResponse.json({ error: 'Utilisateur introuvable' }, { status: 404 });
}
return NextResponse.json(user);
} catch (error) {
console.error('Error fetching user profile:', error);
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}

View File

@@ -1,31 +1,22 @@
"use client";
import React, { useEffect } from 'react';
import React from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { notFound } from 'next/navigation';
import { ArrowLeft, User, Calendar, Share2 } from 'lucide-react';
import { MOCK_BLOG_POSTS } from '../../../lib/mockData';
import { prisma } from '../../../lib/prisma';
const BlogPostPage = () => {
const { id } = useParams();
const post = MOCK_BLOG_POSTS.find(p => p.id === id);
interface Props {
params: Promise<{ id: string }>;
}
// Scroll to top when loading a new post
useEffect(() => {
window.scrollTo(0, 0);
}, [id]);
export default async function BlogPostPage({ params }: Props) {
const { id } = await params;
const post = await prisma.blogPost.findUnique({
where: { id }
});
if (!post) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center">
<h2 className="text-3xl font-serif font-bold text-gray-900 mb-4">Article introuvable</h2>
<p className="text-gray-600 mb-8">L'article que vous recherchez n'existe pas ou a é supprimé.</p>
<Link href="/blog" className="text-brand-600 hover:text-brand-700 font-medium flex items-center">
<ArrowLeft className="w-4 h-4 mr-2" /> Retour au blog
</Link>
</div>
);
notFound();
}
return (
@@ -56,7 +47,7 @@ const BlogPostPage = () => {
</span>
<span className="flex items-center">
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
{post.date}
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
Conseils
@@ -68,18 +59,14 @@ const BlogPostPage = () => {
</header>
{/* Content */}
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
<p className="lead text-xl text-gray-500 font-serif italic mb-6">
<div className="prose prose-lg prose-orange max-w-none text-gray-600 break-words overflow-hidden">
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
{post.excerpt}
</p>
{/* Rendering paragraphs manually for the mock data */}
{post.content.split('\n').map((paragraph, index) => (
paragraph.trim() !== '' && (
<p key={index} className="mb-4 leading-relaxed">
{paragraph}
</p>
)
))}
<div
className="mt-6"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</div>
{/* Footer / Share */}
@@ -95,6 +82,5 @@ const BlogPostPage = () => {
</div>
</article>
);
};
}
export default BlogPostPage;

View File

@@ -1,58 +1,69 @@
"use client";
import React from 'react';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import { MOCK_BLOG_POSTS } from '../../lib/mockData';
import { prisma } from '../../lib/prisma';
const BlogPage = () => (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
export const revalidate = 3600; // Revalidate every hour
export default async function BlogPage() {
const posts = await prisma.blogPost.findMany({
orderBy: { createdAt: 'desc' }
});
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<h1 className="text-3xl font-bold font-serif text-gray-900 sm:text-4xl">Le Blog de l'Entrepreneur</h1>
<p className="mt-3 max-w-2xl mx-auto text-xl text-gray-500 sm:mt-4">
Conseils, actualités et success stories de l'écosystème africain.
Conseils, actualités et success stories de l'écosystème africain.
</p>
</div>
<div className="grid gap-8 lg:grid-cols-3">
{MOCK_BLOG_POSTS.map(post => (
<Link key={post.id} href={`/blog/${post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
<div className="flex-shrink-0 relative overflow-hidden h-48">
<img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} />
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
</div>
<div className="flex-1 bg-white p-6 flex flex-col justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-brand-600">Conseils</p>
<div className="block mt-2">
<p className="text-xl font-semibold text-gray-900 group-hover:text-brand-700 transition-colors">{post.title}</p>
<p className="mt-3 text-base text-gray-500 line-clamp-3">{post.excerpt}</p>
</div>
</div>
<div className="mt-6 flex items-center justify-between">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="sr-only">{post.author}</span>
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-bold border border-gray-300">
{post.author.charAt(0)}
</div>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">{post.author}</p>
<div className="flex space-x-1 text-sm text-gray-500">
<time>{post.date}</time>
</div>
</div>
</div>
<span className="text-brand-600 opacity-0 group-hover:opacity-100 transition-opacity">
<ArrowRight className="w-5 h-5" />
</span>
</div>
</div>
</Link>
))}
</div>
</div>
);
</div>
{posts.length === 0 ? (
<div className="text-center py-20 bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200">
<p className="text-gray-500">Aucun article n'a encore été publié.</p>
<p className="text-sm text-gray-400 mt-2">Revenez bientôt pour de nouveaux contenus !</p>
</div>
) : (
<div className="grid gap-8 lg:grid-cols-3">
{posts.map(post => (
<Link key={post.id} href={`/blog/${post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
<div className="flex-shrink-0 relative overflow-hidden h-48">
<img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} />
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
</div>
<div className="flex-1 bg-white p-6 flex flex-col justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-brand-600">Actualité</p>
<div className="block mt-2">
<p className="text-xl font-semibold text-gray-900 group-hover:text-brand-700 transition-colors line-clamp-2">{post.title}</p>
<p className="mt-3 text-base text-gray-500 line-clamp-3">{post.excerpt}</p>
</div>
</div>
<div className="mt-6 flex items-center justify-between">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-10 w-10 rounded-full bg-brand-50 flex items-center justify-center text-brand-600 font-bold border border-brand-100 uppercase">
{post.author.charAt(0)}
</div>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">{post.author}</p>
<div className="flex space-x-1 text-sm text-gray-500">
<time>{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}</time>
</div>
</div>
</div>
<span className="text-brand-600 opacity-0 group-hover:opacity-100 transition-opacity">
<ArrowRight className="w-5 h-5" />
</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
export default BlogPage;

View File

@@ -3,12 +3,14 @@
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye, MessageSquare, User as UserIcon, Rocket } 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';
import DashboardMessages from '../../components/dashboard/DashboardMessages';
import DashboardOffers from '../../components/dashboard/DashboardOffers';
import PricingSection from '../../components/PricingSection';
import { useUser } from '../../components/UserProvider';
@@ -16,14 +18,25 @@ import { useUser } from '../../components/UserProvider';
const DashboardPage = () => {
const { user, logout } = useUser();
const router = useRouter();
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription'>('overview');
const searchParams = useSearchParams();
const chatId = searchParams.get('chatId') || searchParams.get('chat');
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 [loading, setLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
setIsMounted(true);
}, []);
// Handle view from URL
const viewParam = searchParams.get('view');
if (viewParam && ['overview', 'profile', 'offers', 'subscription', 'messages', 'personal-profile'].includes(viewParam)) {
setCurrentView(viewParam as any);
}
}, [searchParams]);
// Fetch user's business
useEffect(() => {
@@ -37,6 +50,17 @@ const DashboardPage = () => {
const data = await res.json();
if (data && !data.error) {
setBusiness(data);
// Fetch stats for this business
try {
const statsRes = await fetch(`/api/analytics/businesses/${data.id}`);
const statsData = await statsRes.json();
if (!statsData.error) {
setStats(statsData);
}
} catch (e) {
console.error('Error fetching stats:', e);
}
}
} catch (error) {
console.error('Error fetching dashboard business:', error);
@@ -48,14 +72,60 @@ const DashboardPage = () => {
fetchBusiness();
}, [user, isMounted]);
if (!isMounted) return null;
// Fetch unread messages count
const fetchUnreadCount = async () => {
if (!user || !isMounted) return;
// If user has no id, try to find it via email (Session Reconciliation)
let effectiveUserId = user.id;
if (!effectiveUserId || effectiveUserId === 'undefined' || effectiveUserId === 'null') {
try {
const res = await fetch(`/api/users/profile?email=${encodeURIComponent(user.email)}`);
const data = await res.json();
if (data.id) {
effectiveUserId = data.id;
// Note: Ideally we'd update the global user object here too
} else {
return; // Can't count without ID
}
} catch (e) { return; }
}
if (!user) {
if (typeof window !== 'undefined') router.push('/login');
return null;
}
try {
const res = await fetch('/api/messages/unread', {
headers: { 'x-user-id': effectiveUserId }
});
const data = await res.json();
if (!data.error) {
setUnreadCount(data.count);
}
} catch (error) {
console.error('Error fetching unread count:', error);
}
};
if (loading) {
useEffect(() => {
fetchUnreadCount();
const interval = setInterval(fetchUnreadCount, 15000); // Polling every 15s
return () => clearInterval(interval);
}, [user, isMounted]);
// Update document title with unread count
useEffect(() => {
if (unreadCount > 0) {
document.title = `(${unreadCount}) Messages - Afropreunariat`;
} else {
document.title = 'Tableau de Bord - Afropreunariat';
}
}, [unreadCount]);
useEffect(() => {
if (!user && isMounted) {
router.push('/login');
}
}, [user, isMounted, router]);
if (!isMounted || (loading && user)) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center">
@@ -66,6 +136,10 @@ const DashboardPage = () => {
);
}
if (!user) {
return null;
}
// Default business structure for new users
const displayBusiness = business || {
id: 'new',
@@ -76,6 +150,7 @@ const DashboardPage = () => {
logoUrl: "https://picsum.photos/200/200?random=default",
contactEmail: user?.email || "",
contactPhone: "",
slug: "",
tags: [],
socialLinks: {},
verified: false,
@@ -96,15 +171,21 @@ const DashboardPage = () => {
</div>
<div className="flex-1 flex flex-col overflow-y-auto pt-5 pb-4">
<div className="px-4 mb-6">
<div className="flex items-center">
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold shrink-0">
<div className="flex items-center relative group">
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-brand-400 to-brand-600 flex items-center justify-center text-white font-bold shrink-0 relative shadow-lg shadow-brand-200 border-2 border-white">
{user.name.charAt(0)}
{unreadCount > 0 && (
<>
<span className="absolute -top-1.5 -right-1.5 block h-4 w-4 rounded-full bg-red-600 border-2 border-white animate-bounce"></span>
<span className="absolute -top-1.5 -right-1.5 block h-4 w-4 rounded-full bg-red-400 animate-ping opacity-75"></span>
</>
)}
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-700 truncate">{user.name}</p>
<div className="flex items-center mt-1">
<span className="h-2 w-2 rounded-full bg-green-400 mr-1"></span>
<span className="text-xs text-gray-500">Actif</span>
<p className="text-sm font-bold text-gray-900 truncate">{user.name}</p>
<div className="flex items-center mt-0.5">
<span className="h-2 w-2 rounded-full bg-green-500 mr-1.5 shadow-[0_0_8px_rgba(34,197,94,0.6)]"></span>
<span className="text-[10px] font-bold text-green-600 uppercase tracking-tighter">En ligne</span>
</div>
</div>
</div>
@@ -114,18 +195,53 @@ const DashboardPage = () => {
<LayoutDashboard className={`${currentView === 'overview' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Tableau de Bord
</button>
<button onClick={() => setCurrentView('profile')} className={`${currentView === 'profile' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
<Edit3 className={`${currentView === 'profile' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Éditer mon profil
<button onClick={() => setCurrentView('messages')} className={`${currentView === 'messages' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full relative`}>
<MessageSquare className={`${currentView === 'messages' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Messages
{unreadCount > 0 && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white shadow-lg shadow-red-200">
{unreadCount}
</span>
<span className="flex h-2 w-2 rounded-full bg-red-500 animate-pulse"></span>
</span>
)}
</button>
<button onClick={() => setCurrentView('offers')} className={`${currentView === 'offers' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
<ShoppingBag className={`${currentView === 'offers' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Mes Offres
</button>
<button onClick={() => setCurrentView('subscription')} className={`${currentView === 'subscription' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
<CreditCard className={`${currentView === 'subscription' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Abonnement
<button onClick={() => setCurrentView('personal-profile')} className={`${currentView === 'personal-profile' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
<UserIcon className={`${currentView === 'personal-profile' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Mon Profil Client
</button>
<div className="pt-4 pb-2">
<p className="px-3 text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Espace Business</p>
</div>
{user.role === 'ENTREPRENEUR' ? (
<>
<button onClick={() => setCurrentView('profile')} className={`${currentView === 'profile' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
<Edit3 className={`${currentView === 'profile' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Ma Fiche Entreprise
</button>
<button onClick={() => setCurrentView('offers')} className={`${currentView === 'offers' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
<ShoppingBag className={`${currentView === 'offers' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Mes Offres
</button>
<button onClick={() => setCurrentView('subscription')} className={`${currentView === 'subscription' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
<CreditCard className={`${currentView === 'subscription' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
Abonnement
</button>
</>
) : (
<button
onClick={() => setCurrentView('profile')}
className="mt-2 group flex items-center px-2 py-3 text-sm font-bold rounded-md w-full bg-brand-600 text-white hover:bg-brand-700 transition-all shadow-md group border-2 border-brand-400"
>
<Rocket className="mr-3 flex-shrink-0 h-5 w-5 text-brand-200" />
Propulser mon Business
</button>
)}
</nav>
</div>
<div className="flex-shrink-0 flex border-t border-gray-200 p-4">
@@ -143,7 +259,9 @@ const DashboardPage = () => {
<header className="bg-white shadow-sm z-10 flex justify-between items-center px-6 py-4 sticky top-0">
<h1 className="text-2xl font-bold text-gray-900 sm:truncate">
{currentView === 'overview' && 'Vue d\'ensemble'}
{currentView === 'profile' && 'Mon Profil'}
{currentView === 'messages' && 'Mes Messages'}
{currentView === 'personal-profile' && 'Mon Profil Client'}
{currentView === 'profile' && (user.role === 'ENTREPRENEUR' ? 'Ma Fiche Entreprise' : 'Créer mon Business')}
{currentView === 'offers' && 'Gestion des Offres'}
{currentView === 'subscription' && 'Mon Abonnement'}
</h1>
@@ -156,8 +274,28 @@ const DashboardPage = () => {
</header>
<main className="flex-1 overflow-y-auto p-6">
<div className="max-w-7xl mx-auto">
{currentView === 'overview' && <DashboardOverview business={displayBusiness} />}
<div className="max-w-7xl mx-auto space-y-6">
{business?.isSuspended && (
<div className="bg-orange-50 border-l-4 border-orange-500 p-4 rounded-r-lg shadow-sm animate-in fade-in slide-in-from-top duration-500">
<div className="flex">
<div className="flex-shrink-0">
<ShieldAlert className="h-5 w-5 text-orange-500" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-orange-700 font-bold uppercase tracking-tight">Boutique Suspendue</p>
<p className="text-sm text-orange-600 mt-1 italic">
"{business.suspensionReason || "Votre boutique a é temporairement masquée suite à une décision de la modération."}"
</p>
<p className="text-xs text-orange-500 mt-2 font-medium">
Veuillez contacter l'administration pour plus d'informations.
</p>
</div>
</div>
</div>
)}
{currentView === 'overview' && <DashboardOverview business={displayBusiness} stats={stats} />}
{currentView === 'messages' && <DashboardMessages onMessagesRead={fetchUnreadCount} initialConversationId={chatId} />}
{currentView === 'personal-profile' && <DashboardProfileClient />}
{currentView === 'profile' && <DashboardProfile business={displayBusiness} setBusiness={setBusiness} />}
{currentView === 'offers' && <DashboardOffers />}
{currentView === 'subscription' && (

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--font-sans: 'Inter', sans-serif;

77
app/suspended/page.tsx Normal file
View File

@@ -0,0 +1,77 @@
"use client";
import React from 'react';
import { useUser } from '@/components/UserProvider';
import { ShieldAlert, Mail, ArrowLeft, LogOut } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export default function SuspendedPage() {
const { user, logout } = useUser();
const router = useRouter();
const handleLogout = () => {
logout();
router.push('/');
};
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl border border-rose-100 overflow-hidden">
<div className="bg-rose-600 p-8 flex flex-col items-center text-white text-center">
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mb-4">
<ShieldAlert className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold mb-2">Compte Suspendu</h1>
<p className="text-rose-100 text-sm">
Votre accès aux fonctionnalités de la plateforme a é restreint.
</p>
</div>
<div className="p-8 space-y-6">
<div>
<h2 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Motif de la suspension</h2>
<div className="bg-slate-50 border border-slate-100 rounded-xl p-4 text-slate-700 italic text-sm leading-relaxed">
"{user?.suspensionReason || "Non respect des conditions générales d'utilisation ou comportement inapproprié constaté par la modération."}"
</div>
</div>
<div className="space-y-4">
<p className="text-sm text-slate-600">
Si vous pensez qu'il s'agit d'une erreur ou si vous souhaitez contester cette décision, vous pouvez contacter notre équipe de modération.
</p>
<div className="flex items-center gap-3 p-4 bg-indigo-50 rounded-xl border border-indigo-100">
<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>
</div>
</div>
</div>
<div className="flex flex-col gap-3 pt-4 border-t border-slate-100">
<button
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-slate-900 text-white rounded-xl font-bold text-sm hover:bg-slate-800 transition-all shadow-lg active:scale-95"
>
<LogOut className="w-4 h-4" />
Se déconnecter
</button>
<Link href="/" className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-white text-slate-600 rounded-xl font-bold text-sm hover:bg-slate-50 transition-all border border-slate-200">
<ArrowLeft className="w-4 h-4" />
Retour à l'accueil
</Link>
</div>
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100 text-center">
<p className="text-[10px] text-slate-400">
Date de suspension : {new Date().toLocaleDateString('fr-FR')}
</p>
</div>
</div>
</div>
);
}