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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
38
app/api/admin/reports/[id]/route.ts
Normal file
38
app/api/admin/reports/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
53
app/api/admin/reports/route.ts
Normal file
53
app/api/admin/reports/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
55
app/api/analytics/businesses/[id]/route.ts
Normal file
55
app/api/analytics/businesses/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
35
app/api/conversations/[id]/archive/route.ts
Normal file
35
app/api/conversations/[id]/archive/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
60
app/api/conversations/[id]/route.ts
Normal file
60
app/api/conversations/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
60
app/api/conversations/route.ts
Normal file
60
app/api/conversations/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
52
app/api/messages/[id]/report/route.ts
Normal file
52
app/api/messages/[id]/report/route.ts
Normal 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
115
app/api/messages/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
29
app/api/messages/unread/route.ts
Normal file
29
app/api/messages/unread/route.ts
Normal 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
39
app/api/users/me/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
33
app/api/users/profile/route.ts
Normal file
33
app/api/users/profile/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 été 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 été 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' && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
|
||||
77
app/suspended/page.tsx
Normal file
77
app/suspended/page.tsx
Normal 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 été 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user