first commit

This commit is contained in:
2026-02-22 20:25:47 +01:00
commit a4f85b0b7b
31 changed files with 5870 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
import React, { useState } from 'react';
import { PlusCircle, Edit3, Trash2, ShoppingBag, Image as ImageIcon } from 'lucide-react';
import { Offer, OfferType } from '../../types';
import { MOCK_OFFERS } from '../../services/mockData';
const DashboardOffers = () => {
const [offers, setOffers] = useState<Offer[]>(MOCK_OFFERS);
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentOffer, setCurrentOffer] = useState<Partial<Offer>>({
title: '',
price: 0,
currency: 'XOF',
type: OfferType.SERVICE
});
const handleDelete = (id: string) => {
if (window.confirm("Voulez-vous vraiment supprimer cette offre ?")) {
setOffers(offers.filter(o => o.id !== id));
}
};
const handleEdit = (offer: Offer) => {
setCurrentOffer(offer);
setIsModalOpen(true);
};
const openAddModal = () => {
setCurrentOffer({
title: '',
price: 0,
currency: 'XOF',
type: OfferType.SERVICE
});
setIsModalOpen(true);
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
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
};
setOffers([...offers, offer]);
}
setIsModalOpen(false);
};
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">
<PlusCircle className="w-4 h-4 mr-2" />
Ajouter une offre
</button>
</div>
{/* List View */}
{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 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'}`}>
{offer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
</span>
</div>
</div>
<div className="p-4">
<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.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">
<Edit3 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(offer.id)} className="p-1 text-gray-400 hover:text-red-600" title="Supprimer">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<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">Aucune offre</h3>
<p className="mt-1 text-sm text-gray-500">Commencez à vendre vos produits ou services.</p>
</div>
)}
{/* Modal / Drawer */}
{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>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</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">
<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" />
</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">
{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>
<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>
</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" />
</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)})} />
</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})}>
<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>
</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>
<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">
Annuler
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
};
export default DashboardOffers;

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { Eye, Star } from 'lucide-react';
import { Business } from '../../types';
const MousePointerClick = ({className}: {className?:string}) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M14 4.1 12 6" /><path d="m5.1 8-2.9-.8" /><path d="m6 12-1.9 2" /><path d="M7.2 2.2 8 5.1" /><path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z" />
</svg>
);
const DashboardOverview = ({ business }: { business: Business }) => (
<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">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 bg-brand-100 rounded-md p-3">
<Eye className="h-6 w-6 text-brand-600" />
</div>
<div className="ml-5 w-0 flex-1">
<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>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 bg-blue-100 rounded-md p-3">
<MousePointerClick className="h-6 w-6 text-blue-600" />
</div>
<div className="ml-5 w-0 flex-1">
<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>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 bg-yellow-100 rounded-md p-3">
<Star className="h-6 w-6 text-yellow-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Note moyenne</dt>
<dd>
<div className="text-lg font-medium text-gray-900">{business.rating}/5</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Performances 30 derniers jours</h3>
<div className="h-64 bg-gray-50 rounded border border-dashed border-gray-200 flex items-center justify-center text-gray-400">
Graphique des visites (À venir en Phase 2)
</div>
</div>
</div>
);
export default DashboardOverview;

View File

@@ -0,0 +1,185 @@
import React, { useState } from 'react';
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram } from 'lucide-react';
import { Business, CATEGORIES } from '../../types';
import { generateBusinessDescription } from '../../services/geminiService';
// Helper to extract youtube ID
const getYouTubeId = (url: string) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
const DashboardProfile = ({ business, setBusiness }: { business: Business, setBusiness: (b: Business) => void }) => {
const [formData, setFormData] = useState(business);
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
const [isGenerating, setIsGenerating] = useState(false);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSocialChange = (network: keyof typeof formData.socialLinks, value: string) => {
setFormData({
...formData,
socialLinks: { ...formData.socialLinks, [network]: value }
});
};
const handleVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const url = e.target.value;
setVideoInput(url);
setVideoPreviewId(getYouTubeId(url));
setFormData({ ...formData, videoUrl: url });
};
const handleAiGenerate = async () => {
setIsGenerating(true);
const text = await generateBusinessDescription(formData.name, formData.category, "Innovation, Qualité, Service client");
setFormData(prev => ({ ...prev, description: text }));
setIsGenerating(false);
};
const handleSave = () => {
setBusiness(formData);
alert('Profil mis à jour avec succès !');
};
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
</button>
</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>
<div className="flex items-center space-x-6">
<div className="shrink-0">
<img className="h-24 w-24 object-cover rounded-full border-2 border-gray-200" src={formData.logoUrl} alt="Logo actuel" />
</div>
<div className="flex-1 border-2 border-dashed border-gray-300 rounded-md p-6 flex flex-col items-center justify-center hover:border-brand-400 transition-colors cursor-pointer bg-gray-50">
<ImageIcon className="h-8 w-8 text-gray-400" />
<p className="mt-1 text-xs text-gray-500">Glissez votre logo ici ou cliquez pour parcourir</p>
</div>
</div>
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6 mt-6">
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-gray-700">Nom de l'entreprise</label>
<input type="text" name="name" value={formData.name} 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>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-gray-700">Secteur d'activité</label>
<select name="category" value={formData.category} 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">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="sm:col-span-6">
<div className="flex justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">Description</label>
<button type="button" onClick={handleAiGenerate} disabled={isGenerating} className="text-xs flex items-center text-brand-600 hover:text-brand-800">
{isGenerating ? '...' : <><Sparkles className="w-3 h-3 mr-1"/> Générer avec IA</>}
</button>
</div>
<textarea name="description" rows={3} value={formData.description} 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" />
</div>
</div>
</div>
{/* B. Bloc Présentation Vidéo */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><Youtube className="w-5 h-5 mr-2 text-red-600"/> Présentation Vidéo</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Lien de votre vidéo (Youtube)</label>
<div className="mt-1 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 sm:text-sm">
https://
</span>
<input
type="text"
value={videoInput.replace('https://', '')}
onChange={handleVideoChange}
className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md focus:ring-brand-500 focus:border-brand-500 sm:text-sm border-gray-300"
placeholder="www.youtube.com/watch?v=..."
/>
</div>
<p className="mt-2 text-sm text-gray-500">Copiez l'URL de votre vidéo de présentation pour l'afficher sur votre profil.</p>
</div>
{videoPreviewId ? (
<div className="aspect-w-16 aspect-h-9 rounded-lg overflow-hidden bg-gray-100 mt-4 border border-gray-200">
<iframe
src={`https://www.youtube.com/embed/${videoPreviewId}`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-64 sm:h-80 rounded-lg"
></iframe>
</div>
) : videoInput && (
<div className="rounded-md bg-red-50 p-4 mt-4">
<div className="flex">
<div className="flex-shrink-0">
<X className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Lien invalide</h3>
<div className="mt-2 text-sm text-red-700">
<p>Impossible de détecter une vidéo YouTube valide. Vérifiez le lien.</p>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* C. Bloc Coordonnées & Réseaux */}
<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>
<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>
<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="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">
<Facebook className="w-4 h-4"/>
</span>
<input type="text" placeholder="Lien Facebook" value={formData.socialLinks?.facebook || ''} onChange={(e) => handleSocialChange('facebook', e.target.value)} className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md focus:ring-brand-500 focus:border-brand-500 sm:text-sm border-gray-300" />
</div>
<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">
<Linkedin className="w-4 h-4"/>
</span>
<input type="text" placeholder="Lien LinkedIn" value={formData.socialLinks?.linkedin || ''} onChange={(e) => handleSocialChange('linkedin', e.target.value)} className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md focus:ring-brand-500 focus:border-brand-500 sm:text-sm border-gray-300" />
</div>
<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">
<Instagram className="w-4 h-4"/>
</span>
<input type="text" placeholder="Lien Instagram" value={formData.socialLinks?.instagram || ''} onChange={(e) => handleSocialChange('instagram', e.target.value)} className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md focus:ring-brand-500 focus:border-brand-500 sm:text-sm border-gray-300" />
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default DashboardProfile;