Maj dashboard, boutique + ajout de produit
This commit is contained in:
@@ -1,24 +1,73 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { PlusCircle, Edit3, Trash2, ShoppingBag, Image as ImageIcon } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PlusCircle, Edit3, Trash2, ShoppingBag, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { Offer, OfferType } from '../../types';
|
||||
import { MOCK_OFFERS } from '../../lib/mockData';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const DashboardOffers = () => {
|
||||
const [offers, setOffers] = useState<Offer[]>(MOCK_OFFERS);
|
||||
interface DashboardOffersProps {
|
||||
businessId: string;
|
||||
}
|
||||
|
||||
const DashboardOffers = ({ businessId }: DashboardOffersProps) => {
|
||||
const [offers, setOffers] = useState<Offer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [currentOffer, setCurrentOffer] = useState<Partial<Offer>>({
|
||||
title: '',
|
||||
price: 0,
|
||||
currency: 'XOF',
|
||||
type: OfferType.SERVICE
|
||||
type: OfferType.SERVICE,
|
||||
description: '',
|
||||
imageUrl: '',
|
||||
active: true
|
||||
});
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const fetchOffers = async () => {
|
||||
if (!businessId || businessId === 'new') {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/offers?businessId=${businessId}`);
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
toast.error(data.error);
|
||||
} else {
|
||||
setOffers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching offers:', error);
|
||||
toast.error('Erreur lors du chargement des offres');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchOffers();
|
||||
}, [businessId]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm("Voulez-vous vraiment supprimer cette offre ?")) {
|
||||
setOffers(offers.filter(o => o.id !== id));
|
||||
try {
|
||||
const res = await fetch(`/api/offers/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
toast.error(data.error);
|
||||
} else {
|
||||
setOffers(offers.filter(o => o.id !== id));
|
||||
toast.success('Offre supprimée');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
toast.error('Erreur lors de la suppression');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,39 +81,88 @@ const DashboardOffers = () => {
|
||||
title: '',
|
||||
price: 0,
|
||||
currency: 'XOF',
|
||||
type: OfferType.SERVICE
|
||||
type: OfferType.SERVICE,
|
||||
description: '',
|
||||
imageUrl: '',
|
||||
active: true,
|
||||
businessId: businessId
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = (e: React.FormEvent) => {
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
if (currentOffer.id) {
|
||||
// Edit mode
|
||||
setOffers(offers.map(o => o.id === currentOffer.id ? { ...o, ...currentOffer } as Offer : o));
|
||||
} else {
|
||||
// Add mode
|
||||
const offer: Offer = {
|
||||
id: Date.now().toString(),
|
||||
businessId: '1',
|
||||
title: currentOffer.title || 'Nouvelle offre',
|
||||
type: currentOffer.type || OfferType.PRODUCT,
|
||||
price: currentOffer.price || 0,
|
||||
currency: currentOffer.currency as 'EUR'|'XOF',
|
||||
imageUrl: currentOffer.imageUrl || 'https://picsum.photos/300/200?random=' + Date.now(),
|
||||
active: true
|
||||
try {
|
||||
const isEdit = !!currentOffer.id;
|
||||
const url = isEdit ? `/api/offers/${currentOffer.id}` : '/api/offers';
|
||||
const method = isEdit ? 'PATCH' : 'POST';
|
||||
|
||||
// Generate a random image if none provided
|
||||
const finalImageUrl = currentOffer.imageUrl || `https://picsum.photos/400/300?random=${Date.now()}`;
|
||||
|
||||
const payload = {
|
||||
...currentOffer,
|
||||
businessId,
|
||||
imageUrl: finalImageUrl,
|
||||
price: Number(currentOffer.price)
|
||||
};
|
||||
setOffers([...offers, offer]);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
toast.error(data.error);
|
||||
} else {
|
||||
if (isEdit) {
|
||||
setOffers(offers.map(o => o.id === data.id ? data : o));
|
||||
} else {
|
||||
setOffers([data, ...offers]);
|
||||
}
|
||||
toast.success(isEdit ? 'Offre mise à jour' : 'Offre publiée');
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('Erreur lors de la sauvegarde');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="w-10 h-10 text-brand-600 animate-spin" />
|
||||
<p className="mt-4 text-gray-500 font-medium">Chargement de vos offres...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (businessId === 'new') {
|
||||
return (
|
||||
<div className="text-center py-12 bg-white rounded-lg border-2 border-dashed border-gray-300">
|
||||
<ShoppingBag className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Pas encore d'entreprise</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Veuillez d'abord créer votre fiche entreprise avant d'ajouter des offres.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold font-serif text-gray-900">Mes Offres</h2>
|
||||
<button onClick={openAddModal} className="flex items-center bg-brand-600 text-white px-4 py-2 rounded-md hover:bg-brand-700 font-medium text-sm shadow-sm transition-colors">
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="flex items-center bg-brand-600 text-white px-4 py-2 rounded-md hover:bg-brand-700 font-medium text-sm shadow-sm transition-colors active:scale-95"
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
Ajouter une offre
|
||||
</button>
|
||||
@@ -74,29 +172,34 @@ const DashboardOffers = () => {
|
||||
{offers.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{offers.map(offer => (
|
||||
<div key={offer.id} className="bg-white rounded-lg shadow border border-gray-200 overflow-hidden group">
|
||||
<div key={offer.id} className="bg-white rounded-lg shadow border border-gray-200 overflow-hidden group flex flex-col">
|
||||
<div className="relative h-40 bg-gray-200">
|
||||
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover" />
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className={`px-2 py-1 text-xs font-bold rounded uppercase ${offer.type === OfferType.PRODUCT ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'}`}>
|
||||
<span className={`px-2 py-1 text-[10px] font-bold rounded uppercase ${offer.type === OfferType.PRODUCT ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'}`}>
|
||||
{offer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="p-4 flex-1 flex flex-col">
|
||||
<h3 className="font-bold text-gray-900 truncate">{offer.title}</h3>
|
||||
<p className="text-brand-600 font-bold mt-1">
|
||||
{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-between items-center pt-4 border-t border-gray-100">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${offer.active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{offer.description && (
|
||||
<p className="text-xs text-gray-500 mt-2 line-clamp-2 italic">
|
||||
{offer.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-auto pt-4 border-t border-gray-100 flex justify-between items-center">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${offer.active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{offer.active ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<button onClick={() => handleEdit(offer)} className="p-1 text-gray-400 hover:text-brand-600" title="Modifier">
|
||||
<button onClick={() => handleEdit(offer)} className="p-1.5 text-gray-400 hover:text-brand-600 hover:bg-brand-50 rounded-md transition-colors" title="Modifier">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(offer.id)} className="p-1 text-gray-400 hover:text-red-600" title="Supprimer">
|
||||
<button onClick={() => handleDelete(offer.id)} className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors" title="Supprimer">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -113,72 +216,92 @@ const DashboardOffers = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal / Drawer */}
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onClick={() => setIsModalOpen(false)}></div>
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onClick={() => !saving && setIsModalOpen(false)}></div>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full">
|
||||
<div className="relative z-10 inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full">
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-brand-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<PlusCircle className="h-6 w-6 text-brand-600" />
|
||||
<ShoppingBag className="h-6 w-6 text-brand-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
|
||||
{currentOffer.id ? 'Modifier l\'offre' : 'Ajouter une offre'}
|
||||
</h3>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Type d'offre</label>
|
||||
<div className="mt-2 flex space-x-4">
|
||||
<label className="inline-flex items-center">
|
||||
<input type="radio" className="form-radio text-brand-600" name="type" checked={currentOffer.type === OfferType.PRODUCT} onChange={() => setCurrentOffer({...currentOffer, type: OfferType.PRODUCT})} />
|
||||
<span className="ml-2 text-sm">Produit Physique</span>
|
||||
<label className="inline-flex items-center cursor-pointer">
|
||||
<input type="radio" className="form-radio text-brand-600 w-4 h-4" name="type" checked={currentOffer.type === OfferType.PRODUCT} onChange={() => setCurrentOffer({...currentOffer, type: OfferType.PRODUCT})} />
|
||||
<span className="ml-2 text-sm font-medium text-gray-700">Produit Physique</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center">
|
||||
<input type="radio" className="form-radio text-brand-600" name="type" checked={currentOffer.type === OfferType.SERVICE} onChange={() => setCurrentOffer({...currentOffer, type: OfferType.SERVICE})} />
|
||||
<span className="ml-2 text-sm">Service / Prestation</span>
|
||||
<label className="inline-flex items-center cursor-pointer">
|
||||
<input type="radio" className="form-radio text-brand-600 w-4 h-4" name="type" checked={currentOffer.type === OfferType.SERVICE} onChange={() => setCurrentOffer({...currentOffer, type: OfferType.SERVICE})} />
|
||||
<span className="ml-2 text-sm font-medium text-gray-700">Service / Prestation</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Titre</label>
|
||||
<input type="text" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm" value={currentOffer.title} onChange={e => setCurrentOffer({...currentOffer, title: e.target.value})} placeholder="Ex: Savon Karité Bio" />
|
||||
<input type="text" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm focus:ring-brand-500 focus:border-brand-500" value={currentOffer.title || ''} onChange={e => setCurrentOffer({...currentOffer, title: e.target.value})} placeholder="Ex: Savon Karité Bio" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Description (optionnel)</label>
|
||||
<textarea rows={2} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm focus:ring-brand-500 focus:border-brand-500" value={currentOffer.description || ''} onChange={e => setCurrentOffer({...currentOffer, description: e.target.value})} placeholder="Courte description de l'offre..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Prix</label>
|
||||
<input type="number" required min="0" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm" value={currentOffer.price} onChange={e => setCurrentOffer({...currentOffer, price: parseInt(e.target.value)})} />
|
||||
<input type="number" required min="0" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm focus:ring-brand-500 focus:border-brand-500" value={currentOffer.price || 0} onChange={e => setCurrentOffer({...currentOffer, price: parseInt(e.target.value) || 0})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Devise</label>
|
||||
<select className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm" value={currentOffer.currency} onChange={e => setCurrentOffer({...currentOffer, currency: e.target.value as any})}>
|
||||
<select className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm focus:ring-brand-500 focus:border-brand-500" value={currentOffer.currency || 'XOF'} onChange={e => setCurrentOffer({...currentOffer, currency: e.target.value as any})}>
|
||||
<option value="XOF">FCFA (XOF)</option>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Photo</label>
|
||||
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
|
||||
<div className="space-y-1 text-center">
|
||||
<ImageIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p className="text-xs text-gray-500">PNG, JPG jusqu'à 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="block text-sm font-medium text-gray-700">Lien Image (optionnel)</label>
|
||||
<input type="text" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm focus:ring-brand-500 focus:border-brand-500" value={currentOffer.imageUrl || ''} onChange={e => setCurrentOffer({...currentOffer, imageUrl: e.target.value})} placeholder="https://..." />
|
||||
<p className="mt-1 text-[10px] text-gray-500 italic">Laissez vide pour utiliser une image aléatoire.</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input id="active" type="checkbox" className="h-4 w-4 text-brand-600 focus:ring-brand-500 border-gray-300 rounded" checked={!!currentOffer.active} onChange={e => setCurrentOffer({...currentOffer, active: e.target.checked})} />
|
||||
<label htmlFor="active" className="ml-2 block text-sm text-gray-900">Afficher cette offre publiquement</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-brand-600 text-base font-medium text-white hover:bg-brand-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm">
|
||||
{currentOffer.id ? 'Mettre à jour' : 'Publier l\'offre'}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-6 py-2 bg-brand-600 text-base font-bold text-white hover:bg-brand-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Enregistrement...
|
||||
</>
|
||||
) : (
|
||||
currentOffer.id ? 'Mettre à jour' : 'Publier l\'offre'
|
||||
)}
|
||||
</button>
|
||||
<button type="button" onClick={() => setIsModalOpen(false)} className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
disabled={saving}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, In
|
||||
import { Business, CATEGORIES } from '../../types';
|
||||
import { generateBusinessDescription } from '../../lib/geminiService';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useUser } from '../UserProvider';
|
||||
|
||||
// Helper to extract youtube ID
|
||||
const getYouTubeId = (url: string) => {
|
||||
@@ -15,6 +16,7 @@ const getYouTubeId = (url: string) => {
|
||||
};
|
||||
|
||||
const DashboardProfile = ({ business, setBusiness }: { business: Business, setBusiness: (b: Business) => void }) => {
|
||||
const { login } = useUser();
|
||||
const [formData, setFormData] = useState(business);
|
||||
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
|
||||
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
|
||||
@@ -77,6 +79,9 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
||||
toast.error(data.error);
|
||||
} else {
|
||||
setBusiness(data);
|
||||
if (data.owner && login) {
|
||||
login(data.owner);
|
||||
}
|
||||
toast.success('Profil mis à jour avec succès !');
|
||||
// Optional: refresh page to show in annuaire
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user