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:
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { User } from '../types';
|
||||
import { MOCK_USER } from '../lib/mockData';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
|
||||
interface UserContextType {
|
||||
user: User | null;
|
||||
@@ -14,6 +14,8 @@ const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||
|
||||
export function UserProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
React.useEffect(() => {
|
||||
const savedUser = localStorage.getItem('afro_user');
|
||||
@@ -22,6 +24,13 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Global suspension check
|
||||
React.useEffect(() => {
|
||||
if (user?.isSuspended && pathname !== '/suspended') {
|
||||
router.push('/suspended');
|
||||
}
|
||||
}, [user, pathname, router]);
|
||||
|
||||
const login = (userData: User) => {
|
||||
setUser(userData);
|
||||
localStorage.setItem('afro_user', JSON.stringify(userData));
|
||||
|
||||
421
components/dashboard/DashboardMessages.tsx
Normal file
421
components/dashboard/DashboardMessages.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Send, User as UserIcon, Building2, ChevronLeft, Loader2, MessageSquare, Archive, Inbox, ArchiveRestore, Flag } from 'lucide-react';
|
||||
import { useUser } from '../UserProvider';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
senderId: string;
|
||||
createdAt: string;
|
||||
sender: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
business: {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
ownerId: string;
|
||||
};
|
||||
participants: {
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}[];
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
interface DashboardMessagesProps {
|
||||
onMessagesRead?: () => void;
|
||||
initialConversationId?: string | null;
|
||||
}
|
||||
|
||||
const DashboardMessages = ({ onMessagesRead, initialConversationId }: DashboardMessagesProps) => {
|
||||
const { user } = useUser();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [isMobileListVisible, setIsMobileListVisible] = useState(true);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConversation) {
|
||||
fetchMessages(selectedConversation.id);
|
||||
}
|
||||
}, [selectedConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/conversations', {
|
||||
headers: { 'x-user-id': user?.id || '' }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.error) {
|
||||
setConversations(data);
|
||||
|
||||
// Handle initial auto-selection
|
||||
if (initialConversationId) {
|
||||
const found = data.find((c: Conversation) => c.id === initialConversationId);
|
||||
if (found) {
|
||||
setSelectedConversation(found);
|
||||
setIsMobileListVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching conversations:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMessages = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${id}`, {
|
||||
headers: { 'x-user-id': user?.id || '' }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.error) {
|
||||
setMessages(data);
|
||||
// After fetching messages (which marks them as read in the backend),
|
||||
// notify the parent to refresh the unread count badge
|
||||
if (onMessagesRead) onMessagesRead();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim() || !selectedConversation || sending) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch('/api/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-id': user?.id || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: selectedConversation.id,
|
||||
content: newMessage
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.error) {
|
||||
setMessages([...messages, {
|
||||
...data,
|
||||
sender: { name: user?.name || '' }
|
||||
}]);
|
||||
setNewMessage('');
|
||||
// Refresh conversations list to update last message/order
|
||||
fetchConversations();
|
||||
} else {
|
||||
toast.error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'envoi');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleArchive = async (conversation: Conversation) => {
|
||||
const isCurrentlyArchived = conversation.participants.find(p => p.user.id === user?.id)?.isArchived;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${conversation.id}/archive`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-id': user?.id || ''
|
||||
},
|
||||
body: JSON.stringify({ archived: !isCurrentlyArchived })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success(isCurrentlyArchived ? 'Conversation désarchivée' : 'Conversation archivée');
|
||||
fetchConversations();
|
||||
// If we archived the currently selected conversation, close it
|
||||
if (!isCurrentlyArchived && selectedConversation?.id === conversation.id) {
|
||||
setSelectedConversation(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'archivage');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReportMessage = async (messageId: string) => {
|
||||
if (!window.confirm('Voulez-vous vraiment signaler ce message aux administrateurs ?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/messages/${messageId}/report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-id': user?.id || ''
|
||||
},
|
||||
body: JSON.stringify({ reason: "Signalement rapide" })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('Message signalé avec succès');
|
||||
} else {
|
||||
toast.error(data.error || 'Erreur lors du signalement');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur réseau lors du signalement');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden h-[600px] flex">
|
||||
{/* 1. Conversations Sidebar */}
|
||||
<div className={`${isMobileListVisible ? 'flex' : 'hidden'} md:flex flex-col w-full md:w-80 border-r border-gray-200 h-full`}>
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-gray-900 flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-brand-600" />
|
||||
Messages
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-gray-200/50 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowArchived(false)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-bold rounded-md transition-all ${!showArchived ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
<Inbox className="w-3.5 h-3.5" />
|
||||
Actifs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowArchived(true)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-bold rounded-md transition-all ${showArchived ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5" />
|
||||
Archivés
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{(() => {
|
||||
const filtered = conversations.filter(conv => {
|
||||
const myParticipant = conv.participants.find(p => p.user.id === user?.id);
|
||||
return !!myParticipant?.isArchived === showArchived;
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500 text-sm">
|
||||
<p>{showArchived ? 'Aucune conversation archivée.' : 'Aucune conversation pour le moment.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return filtered.map((conv) => {
|
||||
const lastMsg = conv.messages[0];
|
||||
const isSelected = selectedConversation?.id === conv.id;
|
||||
|
||||
// Determine display name: If current user is business owner, show customer name
|
||||
const isOwner = user?.id === conv.business.ownerId;
|
||||
const partner = conv.participants.find(p => p.user.id !== user?.id)?.user;
|
||||
const displayName = isOwner ? partner?.name : conv.business.name;
|
||||
const displayAvatar = isOwner ? partner?.avatar : conv.business.logoUrl;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
setSelectedConversation(conv);
|
||||
setIsMobileListVisible(false);
|
||||
}}
|
||||
className={`w-full p-4 flex gap-3 hover:bg-gray-50 transition-colors text-left border-b border-gray-100 ${isSelected ? 'bg-brand-50' : ''}`}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0 border border-gray-200">
|
||||
<img src={displayAvatar || conv.business.logoUrl} alt={displayName} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<h4 className="font-bold text-gray-900 truncate text-sm">
|
||||
{displayName}
|
||||
{isOwner && <span className="ml-2 text-[9px] bg-blue-100 text-blue-600 px-1 rounded">Client</span>}
|
||||
</h4>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{lastMsg ? new Date(lastMsg.createdAt).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate mt-1">
|
||||
{lastMsg ? lastMsg.content : 'Pas de message'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Chat Window */}
|
||||
<div className={`${!isMobileListVisible ? 'flex' : 'hidden'} md:flex flex-col flex-1 h-full bg-slate-50 relative`}>
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/* Chat Header */}
|
||||
<div className="p-4 bg-white border-b border-gray-200 flex items-center gap-3">
|
||||
<button onClick={() => setIsMobileListVisible(true)} className="md:hidden p-2 -ml-2 text-gray-500">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Determine header display */}
|
||||
{(() => {
|
||||
const isOwner = user?.id === selectedConversation.business.ownerId;
|
||||
const partner = selectedConversation.participants.find(p => p.user.id !== user?.id)?.user;
|
||||
const displayName = isOwner ? partner?.name : selectedConversation.business.name;
|
||||
const displayAvatar = isOwner ? partner?.avatar : selectedConversation.business.logoUrl;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 overflow-hidden border border-gray-100">
|
||||
<img src={displayAvatar || selectedConversation.business.logoUrl} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900 text-sm">{displayName} {isOwner && "(Client)"}</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-[11px] text-gray-500 italic">En ligne</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={() => handleToggleArchive(selectedConversation)}
|
||||
className="p-2 text-gray-400 hover:text-brand-600 hover:bg-brand-50 rounded-full transition-all title-archive"
|
||||
title={selectedConversation.participants.find(p => p.user.id === user?.id)?.isArchived ? "Désarchiver" : "Archiver"}
|
||||
>
|
||||
{selectedConversation.participants.find(p => p.user.id === user?.id)?.isArchived
|
||||
? <ArchiveRestore className="w-5 h-5" />
|
||||
: <Archive className="w-5 h-5" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.map((msg) => {
|
||||
// Triple-check identity: ID, Email, or Name
|
||||
const isMe = user && (
|
||||
msg.senderId === user.id ||
|
||||
(msg.sender?.email && msg.sender.email === user.email) ||
|
||||
(msg.sender?.name && msg.sender.name === user.name)
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
|
||||
<span className={`text-[10px] mb-1 px-1 font-bold ${isMe ? 'text-brand-600' : 'text-gray-500'}`}>
|
||||
{isMe ? 'Vous' : (user?.id === selectedConversation.business.ownerId
|
||||
? (selectedConversation.participants.find(p => p.user.id !== user?.id)?.user.name || 'Client')
|
||||
: selectedConversation.business.name
|
||||
)}
|
||||
</span>
|
||||
<div className={`max-w-[80%] rounded-2xl p-3 text-sm shadow-xl border-2 transition-all ${
|
||||
isMe
|
||||
? 'bg-brand-600 text-white border-brand-500 rounded-tr-none ring-2 ring-brand-100'
|
||||
: 'bg-white text-gray-800 border-gray-100 rounded-tl-none ring-2 ring-gray-50 shadow-gray-200/50'
|
||||
}`}>
|
||||
<p className="leading-relaxed font-medium">{msg.content}</p>
|
||||
<div className={`flex items-center gap-1 mt-1 ${isMe ? 'justify-end text-brand-100' : 'justify-start text-gray-400'}`}>
|
||||
<span className="text-[9px] font-bold">
|
||||
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{!isMe && (
|
||||
<button
|
||||
onClick={() => handleReportMessage(msg.id)}
|
||||
className="ml-2 hover:text-red-500 transition-colors p-0.5 rounded"
|
||||
title="Signaler ce message"
|
||||
>
|
||||
<Flag className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 bg-white border-t border-gray-200">
|
||||
<form onSubmit={handleSendMessage} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Écrivez votre message..."
|
||||
className="flex-1 bg-gray-100 border-none rounded-full px-4 py-2 text-sm focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newMessage.trim() || sending}
|
||||
className="w-10 h-10 rounded-full bg-brand-600 text-white flex items-center justify-center hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{sending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8 text-center">
|
||||
<div className="w-16 h-16 bg-white rounded-full flex items-center justify-center shadow-sm mb-4">
|
||||
<MessageSquare className="w-8 h-8 text-gray-200" />
|
||||
</div>
|
||||
<h4 className="font-bold text-gray-900 mb-2">Votre messagerie</h4>
|
||||
<p className="text-sm max-w-xs">Sélectionnez une conversation pour commencer à échanger avec vos entrepreneurs préférés.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardMessages;
|
||||
@@ -9,7 +9,7 @@ const MousePointerClick = ({className}: {className?:string}) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DashboardOverview = ({ business }: { business: Business }) => (
|
||||
const DashboardOverview = ({ business, stats }: { business: Business, stats: { totalViews: number, contactClicks: number } | null }) => (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold font-serif text-gray-900">Tableau de bord</h2>
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||
@@ -23,7 +23,7 @@ const DashboardOverview = ({ business }: { business: Business }) => (
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Vues de la fiche</dt>
|
||||
<dd>
|
||||
<div className="text-lg font-medium text-gray-900">{business.viewCount}</div>
|
||||
<div className="text-lg font-medium text-gray-900">{stats ? stats.totalViews : business.viewCount}</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ const DashboardOverview = ({ business }: { business: Business }) => (
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Clics Contact</dt>
|
||||
<dd>
|
||||
<div className="text-lg font-medium text-gray-900">42</div>
|
||||
<div className="text-lg font-medium text-gray-900">{stats ? stats.contactClicks : '...'}</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram } from 'lucide-react';
|
||||
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { Business, CATEGORIES } from '../../types';
|
||||
import { generateBusinessDescription } from '../../lib/geminiService';
|
||||
import { toast } from 'react-hot-toast';
|
||||
@@ -86,15 +86,60 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
||||
}
|
||||
};
|
||||
|
||||
const isNameOk = formData.name && formData.name !== "Nouvelle Entreprise";
|
||||
const isDescOk = formData.description && formData.description.length >= 20;
|
||||
const isLocOk = formData.location && formData.location !== "Ma Ville";
|
||||
const isEmailOk = !!formData.contactEmail;
|
||||
const isPhoneOk = !!formData.contactPhone;
|
||||
const isActive = isNameOk && isDescOk && isLocOk && isEmailOk && isPhoneOk;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold font-serif text-gray-900">Éditer mon profil</h2>
|
||||
<button onClick={handleSave} className="bg-brand-600 text-white px-4 py-2 rounded-md hover:bg-brand-700 font-medium text-sm shadow-sm">
|
||||
Enregistrer
|
||||
<div className="space-y-8 pb-20">
|
||||
<div className="flex justify-between items-center bg-white p-4 rounded-lg shadow-sm sticky top-0 z-10 border border-gray-100">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-bold font-serif text-gray-900">Éditer mon profil</h2>
|
||||
{isActive ? (
|
||||
<span className="px-3 py-1 bg-green-100 text-green-700 text-xs font-bold rounded-full flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" /> Boutique Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-3 py-1 bg-orange-100 text-orange-700 text-xs font-bold rounded-full flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" /> Profil Incomplet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleSave} className="bg-brand-600 text-white px-6 py-2 rounded-lg hover:bg-brand-700 font-bold text-sm shadow-md transition-all active:scale-95">
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isActive && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 flex gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-orange-600 shrink-0" />
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-orange-800">Votre boutique n'est pas encore visible dans l'annuaire</h4>
|
||||
<p className="text-xs text-orange-700 mt-1">Pour être activé, vous devez compléter :</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
||||
<span className={`text-[10px] flex items-center gap-1 ${isNameOk ? 'text-green-600' : 'text-orange-400'}`}>
|
||||
{isNameOk ? '✓' : '○'} Nom de boutique
|
||||
</span>
|
||||
<span className={`text-[10px] flex items-center gap-1 ${isDescOk ? 'text-green-600' : 'text-orange-400'}`}>
|
||||
{isDescOk ? '✓' : '○'} Description (min 20 chars)
|
||||
</span>
|
||||
<span className={`text-[10px] flex items-center gap-1 ${isLocOk ? 'text-green-600' : 'text-orange-400'}`}>
|
||||
{isLocOk ? '✓' : '○'} Localisation
|
||||
</span>
|
||||
<span className={`text-[10px] flex items-center gap-1 ${isEmailOk ? 'text-green-600' : 'text-orange-400'}`}>
|
||||
{isEmailOk ? '✓' : '○'} Email de contact
|
||||
</span>
|
||||
<span className={`text-[10px] flex items-center gap-1 ${isPhoneOk ? 'text-green-600' : 'text-orange-400'}`}>
|
||||
{isPhoneOk ? '✓' : '○'} Numéro de téléphone
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A. Bloc Identité Visuelle */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><ImageIcon className="w-5 h-5 mr-2 text-brand-600" /> Identité Visuelle</h3>
|
||||
@@ -204,16 +249,64 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><Globe className="w-5 h-5 mr-2 text-blue-500" /> Coordonnées & Réseaux</h3>
|
||||
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Email Contact</label>
|
||||
<input type="email" name="contactEmail" value={formData.contactEmail} onChange={handleInputChange} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm" />
|
||||
<div className="sm:col-span-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Localisation (Ville, Pays)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Ex: Abidjan, Côte d'Ivoire"
|
||||
className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Téléphone</label>
|
||||
<input type="text" name="contactPhone" value={formData.contactPhone || ''} onChange={handleInputChange} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm" />
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Email Contact</label>
|
||||
<label className="relative inline-flex items-center cursor-pointer scale-75">
|
||||
<input type="checkbox" className="sr-only peer" checked={formData.showEmail} onChange={(e) => setFormData({...formData, showEmail: e.target.checked})} />
|
||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-600"></div>
|
||||
<span className="ml-2 text-[10px] font-medium text-gray-500">{formData.showEmail ? 'Public' : 'Masqué'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<input type="email" name="contactEmail" value={formData.contactEmail} onChange={handleInputChange} className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm" placeholder="contact@votre-boutique.com" />
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Téléphone</label>
|
||||
<label className="relative inline-flex items-center cursor-pointer scale-75">
|
||||
<input type="checkbox" className="sr-only peer" checked={formData.showPhone} onChange={(e) => setFormData({...formData, showPhone: e.target.checked})} />
|
||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-600"></div>
|
||||
<span className="ml-2 text-[10px] font-medium text-gray-500">{formData.showPhone ? 'Public' : 'Masqué'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<input type="text" name="contactPhone" value={formData.contactPhone || ''} onChange={handleInputChange} className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm" placeholder="+225 ..." />
|
||||
</div>
|
||||
<div className="sm:col-span-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Lien du Site Internet</label>
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 text-sm">
|
||||
https://
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="websiteUrl"
|
||||
placeholder="www.votre-site.com"
|
||||
value={(formData.websiteUrl || '').replace('https://', '')}
|
||||
onChange={(e) => setFormData({...formData, websiteUrl: `https://${e.target.value}`})}
|
||||
className="flex-1 block w-full border border-gray-300 rounded-none rounded-r-md py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-6 border-t border-gray-100 pt-4 mt-2">
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-3 uppercase">Réseaux Sociaux</h4>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-500 uppercase">Réseaux Sociaux</h4>
|
||||
<label className="relative inline-flex items-center cursor-pointer scale-75">
|
||||
<input type="checkbox" className="sr-only peer" checked={formData.showSocials} onChange={(e) => setFormData({...formData, showSocials: e.target.checked})} />
|
||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-600"></div>
|
||||
<span className="ml-2 text-[10px] font-medium text-gray-500">{formData.showSocials ? 'Public' : 'Masqué'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500">
|
||||
|
||||
148
components/dashboard/DashboardProfileClient.tsx
Normal file
148
components/dashboard/DashboardProfileClient.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { User, Mail, Phone, MapPin, FileText, Save, Loader2 } from 'lucide-react';
|
||||
import { useUser } from '../UserProvider';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const DashboardProfileClient = () => {
|
||||
const { user, login } = useUser();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: user?.name || '',
|
||||
email: user?.email || '',
|
||||
phone: user?.phone || '',
|
||||
bio: user?.bio || '',
|
||||
location: user?.location || '',
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/users/me', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-id': user?.id || ''
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.error) {
|
||||
login(data); // Sync local storage/context
|
||||
toast.success('Profil personnel mis à jour !');
|
||||
} else {
|
||||
toast.error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la sauvegarde');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-100 bg-gray-50">
|
||||
<h3 className="text-xl font-bold font-serif text-gray-900">Informations Personnelles</h3>
|
||||
<p className="text-sm text-gray-500">Ces informations sont visibles par les entrepreneurs que vous contactez.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-brand-600" /> Nom Complet
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
placeholder="Votre nom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-brand-600" /> Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-brand-600" /> Téléphone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
placeholder="+225 ..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-brand-600" /> Ville / Pays
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
placeholder="Ex: Abidjan, Côte d'Ivoire"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-brand-600" /> Bio / Présentation
|
||||
</label>
|
||||
<textarea
|
||||
name="bio"
|
||||
value={formData.bio}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all resize-none"
|
||||
placeholder="Parlez-nous un peu de vous..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-100 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-brand-600 text-white font-bold rounded-lg hover:bg-brand-700 transition-all flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardProfileClient;
|
||||
Reference in New Issue
Block a user