Spaces:
Running
Running
| import { useState, useRef, useEffect, useMemo } from 'react'; | |
| import { API_URL } from '../config'; | |
| import { uploadBusinessCover, serializeAmenities } from '../lib/businessSite'; | |
| import { ROUTES } from '../lib/routes'; | |
| import LocationMapPicker from './LocationMapPicker'; | |
| import SahelLogo from './SahelLogo'; | |
| import AmenityIcon from './AmenityIcon'; | |
| const STEPS_COUNT = 3; | |
| const BUSINESS_TYPES = [ | |
| { value: 'Restaurant', label: 'Restaurant / Café' }, | |
| { value: 'Hotel', label: 'Hôtel / Riad' }, | |
| { value: 'Retail', label: 'Boutique / Commerce' }, | |
| { value: 'Services', label: 'Services / Conseil' }, | |
| { value: 'Tech', label: 'Technologie' }, | |
| ]; | |
| const AMENITY_TAGS = [ | |
| { id: 'wifi', label: 'WiFi', icon: 'wifi' }, | |
| { id: 'parking', label: 'Parking', icon: 'local_parking' }, | |
| { id: 'card', label: 'Paiement carte', icon: 'payments' }, | |
| { id: 'delivery', label: 'Livraison', icon: 'delivery_dining' }, | |
| { id: 'access', label: 'Accessibilité', icon: 'accessible' }, | |
| ]; | |
| const CUSTOM_ICON_CHOICES = [ | |
| 'star', | |
| 'restaurant', | |
| 'local_cafe', | |
| 'spa', | |
| 'pool', | |
| 'ac_unit', | |
| 'pets', | |
| 'child_care', | |
| 'storefront', | |
| 'local_laundry_service', | |
| 'room_service', | |
| 'fitness_center', | |
| ]; | |
| const PROCESSING_MESSAGES = [ | |
| 'Nettoyage des métadonnées...', | |
| 'Génération des embeddings vectoriels...', | |
| 'Optimisation du moteur de recherche...', | |
| 'Finalisation de votre mini-site...', | |
| ]; | |
| function parseCoord(value) { | |
| const trimmed = (value || '').trim(); | |
| if (!trimmed) return null; | |
| const parsed = Number.parseFloat(trimmed); | |
| return Number.isFinite(parsed) ? parsed : null; | |
| } | |
| function ProgressBar({ currentStep }) { | |
| return ( | |
| <div className="flex gap-2 h-1.5 w-full"> | |
| {[0, 1, 2].map((index) => ( | |
| <div | |
| key={index} | |
| className={`flex-1 rounded-full ${ | |
| index < currentStep ? 'step-completed' : index === currentStep ? 'step-active' : 'step-inactive' | |
| }`} | |
| /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| function OnboardingProcessing({ uploadProgress }) { | |
| const [statusText, setStatusText] = useState(PROCESSING_MESSAGES[0]); | |
| const [activeProcessStep, setActiveProcessStep] = useState(1); | |
| useEffect(() => { | |
| let messageIndex = 0; | |
| const messageInterval = setInterval(() => { | |
| messageIndex = (messageIndex + 1) % PROCESSING_MESSAGES.length; | |
| setStatusText(PROCESSING_MESSAGES[messageIndex]); | |
| }, 3000); | |
| const stepInterval = setInterval(() => { | |
| setActiveProcessStep((prev) => (prev < 3 ? prev + 1 : prev)); | |
| }, 4500); | |
| return () => { | |
| clearInterval(messageInterval); | |
| clearInterval(stepInterval); | |
| }; | |
| }, []); | |
| const steps = [ | |
| { title: 'Création du profil', detail: 'Enregistrement de votre commerce' }, | |
| { title: 'Photo & localisation', detail: 'Personnalisation du mini-site' }, | |
| { title: 'Indexation IA', detail: uploadProgress || 'Préparation de votre assistant' }, | |
| ]; | |
| return ( | |
| <div className="py-xl flex flex-col items-center justify-center gap-lg text-center animate-subtle-pulse"> | |
| <div className="relative flex justify-center items-center"> | |
| <div className="onboarding-spinner" /> | |
| <div className="absolute w-12 h-12 flex items-center justify-center"> | |
| <span | |
| className="material-symbols-outlined text-primary text-3xl" | |
| style={{ fontVariationSettings: "'FILL' 1" }} | |
| > | |
| auto_awesome | |
| </span> | |
| </div> | |
| </div> | |
| <div className="space-y-xs max-w-md"> | |
| <h2 className="font-headline-md text-headline-md text-on-background m-0"> | |
| Traitement en cours... | |
| </h2> | |
| <p className="font-body-lg text-body-lg text-on-surface-variant m-0">{statusText}</p> | |
| </div> | |
| <div className="bg-surface border border-hairline-border p-md rounded-lg text-left w-full max-w-md shadow-sm"> | |
| {steps.map((step, index) => { | |
| const stepNum = index + 1; | |
| const done = stepNum < activeProcessStep; | |
| const current = stepNum === activeProcessStep; | |
| return ( | |
| <div | |
| key={step.title} | |
| className={`flex items-center gap-sm py-xs ${!done && !current ? 'opacity-50' : ''}`} | |
| > | |
| <span | |
| className={`material-symbols-outlined text-md ${ | |
| done ? 'text-primary' : current ? 'text-primary animate-spin' : 'text-outline' | |
| }`} | |
| style={done ? { fontVariationSettings: "'FILL' 1" } : undefined} | |
| > | |
| {done ? 'check_circle' : current ? 'autorenew' : 'pending'} | |
| </span> | |
| <div> | |
| <p className="font-label-md text-label-md text-on-surface m-0">{step.title}</p> | |
| <p className="font-label-sm text-label-sm text-outline m-0">{step.detail}</p> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default function OnboardingWizard({ owner, ownerToken, onComplete, onExit }) { | |
| const [currentStep, setCurrentStep] = useState(() => { | |
| const saved = localStorage.getItem('sahel_onboarding_step'); | |
| if (saved) { | |
| const step = parseInt(saved, 10); | |
| if (step >= 0 && step < STEPS_COUNT) return step; | |
| } | |
| return 0; | |
| }); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const [uploadProgress, setUploadProgress] = useState(''); | |
| const [selectedAmenities, setSelectedAmenities] = useState(() => { | |
| const saved = localStorage.getItem('sahel_onboarding_amenities'); | |
| if (saved) { | |
| try { | |
| return JSON.parse(saved); | |
| } catch (e) {} | |
| } | |
| return []; | |
| }); | |
| const [showCustomAmenity, setShowCustomAmenity] = useState(false); | |
| const [customLabel, setCustomLabel] = useState(''); | |
| const [customIcon, setCustomIcon] = useState('star'); | |
| const [customEmoji, setCustomEmoji] = useState(''); | |
| const [uploadedFiles, setUploadedFiles] = useState([]); | |
| const [form, setForm] = useState(() => { | |
| const saved = localStorage.getItem('sahel_onboarding_form'); | |
| if (saved) { | |
| try { | |
| return JSON.parse(saved); | |
| } catch (e) {} | |
| } | |
| return { | |
| name: '', | |
| business_type: 'Restaurant', | |
| city: '', | |
| address: '', | |
| latitude: '', | |
| longitude: '', | |
| owner_phone: '', | |
| description: '', | |
| working_hours: '', | |
| primary_services: '', | |
| pasted_text: '', | |
| }; | |
| }); | |
| const [file, setFile] = useState(null); | |
| const [coverFile, setCoverFile] = useState(null); | |
| const [coverPreview, setCoverPreview] = useState(null); | |
| const [dragActive, setDragActive] = useState(false); | |
| const fileInputRef = useRef(null); | |
| const coverInputRef = useRef(null); | |
| useEffect(() => { | |
| localStorage.setItem('sahel_onboarding_step', String(currentStep)); | |
| }, [currentStep]); | |
| useEffect(() => { | |
| localStorage.setItem('sahel_onboarding_amenities', JSON.stringify(selectedAmenities)); | |
| }, [selectedAmenities]); | |
| useEffect(() => { | |
| localStorage.setItem('sahel_onboarding_form', JSON.stringify(form)); | |
| }, [form]); | |
| const handleExit = () => { | |
| localStorage.removeItem('sahel_onboarding_step'); | |
| localStorage.removeItem('sahel_onboarding_amenities'); | |
| localStorage.removeItem('sahel_onboarding_form'); | |
| onExit(); | |
| }; | |
| const mapSearchHint = useMemo(() => { | |
| return [form.address, form.city, form.name].filter(Boolean).join(', '); | |
| }, [form.address, form.city, form.name]); | |
| const handleInputChange = (e) => { | |
| const { name, value } = e.target; | |
| setForm((prev) => ({ ...prev, [name]: value })); | |
| }; | |
| const isAmenitySelected = (tag) => selectedAmenities.some((a) => a.label === tag.label); | |
| const togglePresetAmenity = (tag) => { | |
| setSelectedAmenities((prev) => { | |
| if (prev.some((a) => a.label === tag.label)) { | |
| return prev.filter((a) => a.label !== tag.label); | |
| } | |
| return [...prev, { label: tag.label, icon: tag.icon }]; | |
| }); | |
| }; | |
| const addCustomAmenity = () => { | |
| const label = customLabel.trim(); | |
| if (!label) { | |
| alert('Donnez un nom à votre service.'); | |
| return; | |
| } | |
| if (selectedAmenities.some((a) => a.label.toLowerCase() === label.toLowerCase())) { | |
| alert('Ce service existe déjà.'); | |
| return; | |
| } | |
| const icon = customEmoji.trim() || customIcon; | |
| setSelectedAmenities((prev) => [...prev, { label, icon }]); | |
| setCustomLabel(''); | |
| setCustomEmoji(''); | |
| setShowCustomAmenity(false); | |
| }; | |
| const handleLocationChange = (lat, lng, resolvedAddress) => { | |
| setForm((prev) => ({ | |
| ...prev, | |
| latitude: String(Number(lat).toFixed(6)), | |
| longitude: String(Number(lng).toFixed(6)), | |
| ...(resolvedAddress ? { address: resolvedAddress } : {}), | |
| })); | |
| }; | |
| const handleCoverSelect = (e) => { | |
| const selected = e.target.files?.[0]; | |
| if (!selected) return; | |
| if (!selected.type.startsWith('image/')) { | |
| alert('Veuillez choisir une image (JPG, PNG ou WEBP).'); | |
| return; | |
| } | |
| setCoverFile(selected); | |
| setCoverPreview(URL.createObjectURL(selected)); | |
| }; | |
| const handleDrag = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (e.type === 'dragenter' || e.type === 'dragover') setDragActive(true); | |
| else if (e.type === 'dragleave') setDragActive(false); | |
| }; | |
| const handleDrop = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setDragActive(false); | |
| if (e.dataTransfer.files?.[0]) addDocumentFile(e.dataTransfer.files[0]); | |
| }; | |
| const addDocumentFile = (picked) => { | |
| setFile(picked); | |
| setUploadedFiles([{ name: picked.name, size: picked.size }]); | |
| }; | |
| const handleFileSelect = (e) => { | |
| if (e.target.files?.[0]) addDocumentFile(e.target.files[0]); | |
| }; | |
| const removeDocument = () => { | |
| setFile(null); | |
| setUploadedFiles([]); | |
| if (fileInputRef.current) fileInputRef.current.value = ''; | |
| }; | |
| const nextStep = () => { | |
| if (currentStep === 0) { | |
| if (!form.name.trim()) { | |
| alert('Veuillez saisir le nom de votre entreprise.'); | |
| return; | |
| } | |
| if (!form.owner_phone.trim()) { | |
| alert('Veuillez saisir votre numéro WhatsApp professionnel.'); | |
| return; | |
| } | |
| } | |
| if (currentStep === 1) { | |
| if (!form.description.trim()) { | |
| alert('Veuillez décrire votre activité.'); | |
| return; | |
| } | |
| if (parseCoord(form.latitude) == null || parseCoord(form.longitude) == null) { | |
| alert('Placez votre commerce sur la carte (recherche ou clic sur la carte).'); | |
| return; | |
| } | |
| } | |
| if (currentStep < STEPS_COUNT - 1) { | |
| setCurrentStep((prev) => prev + 1); | |
| } else { | |
| submitOnboarding(); | |
| } | |
| }; | |
| const prevStep = () => { | |
| if (currentStep > 0) setCurrentStep((prev) => prev - 1); | |
| }; | |
| const submitOnboarding = async () => { | |
| setIsSubmitting(true); | |
| setUploadProgress('Création du profil de l\'entreprise...'); | |
| const highlights = serializeAmenities(selectedAmenities); | |
| const publicKnowledge = form.pasted_text.trim(); | |
| try { | |
| const businessPayload = { | |
| name: form.name.trim(), | |
| business_type: form.business_type, | |
| description: form.description.trim(), | |
| owner_email: owner.email, | |
| owner_phone: | |
| '+212' + form.owner_phone.trim().replace(/^\+212/, '').replace(/^0/, ''), | |
| city: form.city.trim(), | |
| address: form.address.trim(), | |
| latitude: parseCoord(form.latitude), | |
| longitude: parseCoord(form.longitude), | |
| working_hours: form.working_hours.trim(), | |
| primary_services: form.primary_services.trim(), | |
| highlights, | |
| public_knowledge: publicKnowledge || null, | |
| owner_id: owner.id, | |
| }; | |
| const response = await fetch(`${API_URL}/businesses`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${ownerToken}`, | |
| }, | |
| body: JSON.stringify(businessPayload), | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({})); | |
| throw new Error(errorData.detail || 'Erreur lors de la création de l\'entreprise.'); | |
| } | |
| let business = await response.json(); | |
| if (coverFile) { | |
| setUploadProgress('Téléversement de la photo de couverture...'); | |
| business = await uploadBusinessCover(business.id, coverFile, ownerToken); | |
| } | |
| if (file) { | |
| setUploadProgress('Indexation de vos documents...'); | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| const uploadRes = await fetch(`${API_URL}/businesses/${business.id}/documents`, { | |
| method: 'POST', | |
| headers: { Authorization: `Bearer ${ownerToken}` }, | |
| body: formData, | |
| }); | |
| if (!uploadRes.ok) { | |
| const errDetail = await uploadRes.json().catch(() => ({})); | |
| alert( | |
| 'Le commerce a été créé, mais l\'indexation du document a échoué : ' + | |
| (errDetail.detail || 'Erreur inconnue'), | |
| ); | |
| } | |
| } else if (form.pasted_text.trim()) { | |
| setUploadProgress('Indexation de vos connaissances...'); | |
| const textBlob = new Blob([form.pasted_text], { type: 'text/plain' }); | |
| const textFile = new File([textBlob], 'profil_entreprise.txt', { type: 'text/plain' }); | |
| const formData = new FormData(); | |
| formData.append('file', textFile); | |
| const uploadRes = await fetch(`${API_URL}/businesses/${business.id}/documents`, { | |
| method: 'POST', | |
| headers: { Authorization: `Bearer ${ownerToken}` }, | |
| body: formData, | |
| }); | |
| if (!uploadRes.ok) { | |
| const errDetail = await uploadRes.json().catch(() => ({})); | |
| alert( | |
| 'Le commerce a été créé, mais l\'indexation du texte a échoué : ' + | |
| (errDetail.detail || 'Erreur inconnue'), | |
| ); | |
| } | |
| } | |
| setUploadProgress('Configuration terminée !'); | |
| localStorage.removeItem('sahel_onboarding_step'); | |
| localStorage.removeItem('sahel_onboarding_amenities'); | |
| localStorage.removeItem('sahel_onboarding_form'); | |
| setTimeout(() => onComplete(business), 800); | |
| } catch (error) { | |
| alert(error.message || 'Une erreur est survenue lors de l\'installation.'); | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| const stepLabels = ['Identité', 'Votre commerce', 'Documents & IA']; | |
| return ( | |
| <div className="bg-warm-bg text-on-surface font-body-md min-h-screen flex flex-col selection:bg-primary-fixed selection:text-on-primary-fixed page-enter"> | |
| <header className="bg-background sticky top-0 z-50 border-b border-hairline-border"> | |
| <div className="flex justify-between items-center px-gutter py-4 w-full max-w-2xl mx-auto"> | |
| <a href={ROUTES.home} className="no-underline"> | |
| <SahelLogo size={28} textClass="font-headline-sm text-headline-sm italic text-on-background" /> | |
| </a> | |
| {currentStep === 0 && !isSubmitting ? ( | |
| <a | |
| href={ROUTES.home} | |
| className="font-label-md text-label-md text-primary no-underline hover:opacity-80" | |
| > | |
| Aide | |
| </a> | |
| ) : onExit ? ( | |
| <button | |
| type="button" | |
| onClick={handleExit} | |
| className="font-label-md text-label-md text-on-surface-variant bg-transparent border-0 cursor-pointer hover:text-primary" | |
| > | |
| Quitter | |
| </button> | |
| ) : ( | |
| <span className="w-16" /> | |
| )} | |
| </div> | |
| </header> | |
| <main className="flex-grow py-lg px-gutter pb-xl"> | |
| <div className="max-w-2xl mx-auto w-full"> | |
| {!isSubmitting && ( | |
| <div className="mb-lg"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <span className="font-label-sm text-label-sm uppercase tracking-wider text-primary"> | |
| Étape {currentStep + 1} sur {STEPS_COUNT} | |
| </span> | |
| <span className="font-label-sm text-label-sm text-on-surface-variant"> | |
| {stepLabels[currentStep]} | |
| </span> | |
| </div> | |
| <ProgressBar currentStep={currentStep} /> | |
| </div> | |
| )} | |
| <div className="bg-surface border border-hairline-border rounded-lg p-md md:p-lg shadow-sm"> | |
| {isSubmitting ? ( | |
| <OnboardingProcessing uploadProgress={uploadProgress} /> | |
| ) : ( | |
| <> | |
| {currentStep === 0 && ( | |
| <div className="space-y-gutter"> | |
| <header> | |
| <h1 className="font-headline-md text-headline-md text-on-background mb-2 m-0"> | |
| Bienvenue | |
| </h1> | |
| <p className="font-body-lg text-body-lg text-on-surface-variant m-0"> | |
| Commençons par les informations de base de votre entreprise. | |
| </p> | |
| </header> | |
| <div className="space-y-md"> | |
| <label className="flex flex-col gap-base"> | |
| <span className="font-label-md text-label-md text-on-surface">Nom de l'entreprise</span> | |
| <input | |
| name="name" | |
| value={form.name} | |
| onChange={handleInputChange} | |
| className="w-full bg-background border border-hairline-border rounded px-sm py-xs focus:ring-1 focus:ring-primary focus:border-primary outline-none font-body-md" | |
| placeholder="Ex: Café de l'Atlas" | |
| type="text" | |
| /> | |
| </label> | |
| <label className="flex flex-col gap-base"> | |
| <span className="font-label-md text-label-md text-on-surface">Type d'activité</span> | |
| <select | |
| name="business_type" | |
| value={form.business_type} | |
| onChange={handleInputChange} | |
| className="w-full bg-background border border-hairline-border rounded px-sm py-xs focus:ring-1 focus:ring-primary focus:border-primary outline-none font-body-md h-[42px]" | |
| > | |
| {BUSINESS_TYPES.map((opt) => ( | |
| <option key={opt.value} value={opt.value}> | |
| {opt.label} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| <label className="flex flex-col gap-base"> | |
| <span className="font-label-md text-label-md text-on-surface">Ville</span> | |
| <input | |
| name="city" | |
| value={form.city} | |
| onChange={handleInputChange} | |
| className="w-full bg-background border border-hairline-border rounded px-sm py-xs focus:ring-1 focus:ring-primary focus:border-primary outline-none font-body-md" | |
| placeholder="Ex: Casablanca" | |
| type="text" | |
| /> | |
| </label> | |
| <label className="flex flex-col gap-base"> | |
| <span className="font-label-md text-label-md text-on-surface"> | |
| WhatsApp professionnel | |
| </span> | |
| <div className="flex"> | |
| <span className="flex items-center px-sm border border-r-0 border-hairline-border bg-surface-container-low text-on-surface-variant rounded-l font-body-md"> | |
| +212 | |
| </span> | |
| <input | |
| name="owner_phone" | |
| value={form.owner_phone} | |
| onChange={handleInputChange} | |
| className="w-full bg-background border border-hairline-border rounded-r px-sm py-xs focus:ring-1 focus:ring-primary focus:border-primary outline-none font-body-md" | |
| placeholder="6 00 00 00 00" | |
| type="tel" | |
| /> | |
| </div> | |
| </label> | |
| </div> | |
| </div> | |
| )} | |
| {currentStep === 1 && ( | |
| <div className="space-y-gutter"> | |
| <header> | |
| <h1 className="font-headline-md text-headline-md text-on-background mb-2 m-0"> | |
| Parlez-nous de votre commerce | |
| </h1> | |
| <p className="font-body-lg text-body-lg text-on-surface-variant m-0"> | |
| Ces détails apparaîtront sur votre mini-site et aideront vos clients à vous trouver. | |
| </p> | |
| </header> | |
| <form className="space-y-gutter" onSubmit={(e) => e.preventDefault()}> | |
| <label className="flex flex-col gap-base"> | |
| <span className="font-label-sm text-label-sm text-on-surface-variant uppercase"> | |
| Description | |
| </span> | |
| <textarea | |
| name="description" | |
| value={form.description} | |
| onChange={handleInputChange} | |
| className="w-full bg-background border border-hairline-border rounded px-sm py-xs focus:ring-1 focus:ring-primary focus:border-primary outline-none resize-none font-body-md" | |
| placeholder="Décrivez brièvement votre activité..." | |
| rows={4} | |
| /> | |
| </label> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-gutter"> | |
| <label className="flex flex-col gap-base"> | |
| <span className="font-label-sm text-label-sm text-on-surface-variant uppercase"> | |
| Horaires | |
| </span> | |
| <div className="relative"> | |
| <input | |
| name="working_hours" | |
| value={form.working_hours} | |
| onChange={handleInputChange} | |
| className="w-full bg-background border border-hairline-border rounded px-sm py-xs pl-10 focus:ring-1 focus:ring-primary focus:border-primary outline-none font-body-md" | |
| placeholder="ex: 09:00 - 18:00" | |
| type="text" | |
| /> | |
| <span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant text-[20px]"> | |
| schedule | |
| </span> | |
| </div> | |
| </label> | |
| <label className="flex flex-col gap-base"> | |
| <span className="font-label-sm text-label-sm text-on-surface-variant uppercase"> | |
| Adresse complète | |
| </span> | |
| <div className="relative"> | |
| <input | |
| name="address" | |
| value={form.address} | |
| onChange={handleInputChange} | |
| className="w-full bg-background border border-hairline-border rounded px-sm py-xs pl-10 focus:ring-1 focus:ring-primary focus:border-primary outline-none font-body-md" | |
| placeholder="Rue Agdal, Casablanca" | |
| type="text" | |
| /> | |
| <span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant text-[20px]"> | |
| location_on | |
| </span> | |
| </div> | |
| </label> | |
| </div> | |
| <div className="flex flex-col gap-sm"> | |
| <span className="font-label-sm text-label-sm text-on-surface-variant uppercase"> | |
| Photo de couverture (mini-site) | |
| </span> | |
| <div | |
| role="button" | |
| tabIndex={0} | |
| onClick={() => coverInputRef.current?.click()} | |
| onKeyDown={(e) => e.key === 'Enter' && coverInputRef.current?.click()} | |
| className="border-2 border-dashed border-outline-variant rounded-lg overflow-hidden cursor-pointer hover:bg-surface-blue/30 transition-colors min-h-[140px] flex items-center justify-center" | |
| > | |
| <input | |
| ref={coverInputRef} | |
| type="file" | |
| accept="image/jpeg,image/png,image/webp" | |
| className="hidden" | |
| onChange={handleCoverSelect} | |
| /> | |
| {coverPreview ? ( | |
| <img | |
| src={coverPreview} | |
| alt="Aperçu couverture" | |
| className="w-full h-48 object-cover" | |
| /> | |
| ) : ( | |
| <div className="flex flex-col items-center gap-xs py-md"> | |
| <span className="material-symbols-outlined text-primary text-3xl"> | |
| add_photo_alternate | |
| </span> | |
| <p className="font-label-md text-label-md text-on-surface-variant m-0 text-center px-sm"> | |
| Téléversez la photo de votre établissement | |
| </p> | |
| <p className="font-label-sm text-label-sm text-outline m-0">JPG, PNG ou WEBP</p> | |
| </div> | |
| )} | |
| </div> | |
| {coverFile ? ( | |
| <p className="font-label-sm text-label-sm text-primary m-0">{coverFile.name}</p> | |
| ) : null} | |
| </div> | |
| <div className="flex flex-col gap-sm"> | |
| <span className="font-label-sm text-label-sm text-on-surface-variant uppercase"> | |
| Services & équipements | |
| </span> | |
| <div className="flex flex-wrap gap-2"> | |
| {AMENITY_TAGS.map((tag) => { | |
| const selected = isAmenitySelected(tag); | |
| return ( | |
| <button | |
| key={tag.id} | |
| type="button" | |
| onClick={() => togglePresetAmenity(tag)} | |
| className={`flex items-center gap-2 px-3 py-1.5 border rounded-full font-label-md text-label-md transition-all ${ | |
| selected | |
| ? 'tag-selected' | |
| : 'border-hairline-border text-on-surface-variant hover:border-primary' | |
| }`} | |
| > | |
| <AmenityIcon icon={tag.icon} className="text-[18px]" filled={selected} onPrimary={selected} /> | |
| <span>{tag.label}</span> | |
| </button> | |
| ); | |
| })} | |
| <button | |
| type="button" | |
| onClick={() => setShowCustomAmenity((v) => !v)} | |
| className={`flex items-center gap-2 px-3 py-1.5 border border-dashed rounded-full font-label-md text-label-md transition-all ${ | |
| showCustomAmenity | |
| ? 'border-primary text-primary bg-surface-blue/30' | |
| : 'border-outline text-outline hover:border-primary hover:text-primary' | |
| }`} | |
| > | |
| <span className="material-symbols-outlined text-[18px]">add</span> | |
| <span>Autre</span> | |
| </button> | |
| </div> | |
| {showCustomAmenity ? ( | |
| <div className="border border-hairline-border rounded-lg p-md bg-surface-container-low space-y-sm"> | |
| <label className="flex flex-col gap-xs"> | |
| <span className="font-label-md text-label-md text-on-surface">Nom du service</span> | |
| <input | |
| type="text" | |
| value={customLabel} | |
| onChange={(e) => setCustomLabel(e.target.value)} | |
| className="w-full bg-background border border-hairline-border rounded px-sm py-xs font-body-md outline-none focus:ring-1 focus:ring-primary" | |
| placeholder="Ex: Brunch du dimanche" | |
| /> | |
| </label> | |
| <div> | |
| <span className="font-label-md text-label-md text-on-surface block mb-xs">Icône</span> | |
| <div className="flex flex-wrap gap-2 mb-sm"> | |
| {CUSTOM_ICON_CHOICES.map((iconName) => ( | |
| <button | |
| key={iconName} | |
| type="button" | |
| onClick={() => { | |
| setCustomIcon(iconName); | |
| setCustomEmoji(''); | |
| }} | |
| className={`w-10 h-10 rounded-lg border flex items-center justify-center ${ | |
| customIcon === iconName && !customEmoji | |
| ? 'border-primary bg-surface-blue' | |
| : 'border-hairline-border bg-background' | |
| }`} | |
| > | |
| <span className="material-symbols-outlined text-primary text-[20px]">{iconName}</span> | |
| </button> | |
| ))} | |
| </div> | |
| <label className="flex flex-col gap-xs"> | |
| <span className="font-label-sm text-label-sm text-on-surface-variant"> | |
| Ou emoji (ex: ☕ 🍕) | |
| </span> | |
| <input | |
| type="text" | |
| maxLength={4} | |
| value={customEmoji} | |
| onChange={(e) => setCustomEmoji(e.target.value)} | |
| className="w-24 bg-background border border-hairline-border rounded px-sm py-xs text-center text-xl outline-none focus:ring-1 focus:ring-primary" | |
| placeholder="🍕" | |
| /> | |
| </label> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={addCustomAmenity} | |
| className="bg-primary text-on-primary px-md py-xs rounded font-label-md border-0 cursor-pointer" | |
| > | |
| Ajouter ce service | |
| </button> | |
| </div> | |
| ) : null} | |
| {selectedAmenities.filter((a) => !AMENITY_TAGS.some((t) => t.label === a.label)).length > 0 ? ( | |
| <div className="flex flex-wrap gap-2 pt-xs"> | |
| {selectedAmenities | |
| .filter((a) => !AMENITY_TAGS.some((t) => t.label === a.label)) | |
| .map((a) => ( | |
| <span | |
| key={a.label} | |
| className="tag-selected flex items-center gap-2 px-3 py-1.5 border rounded-full font-label-md text-label-md" | |
| > | |
| <AmenityIcon icon={a.icon} className="text-[18px]" /> | |
| {a.label} | |
| <button | |
| type="button" | |
| className="border-0 bg-transparent cursor-pointer p-0 text-inherit opacity-80" | |
| onClick={() => | |
| setSelectedAmenities((prev) => prev.filter((x) => x.label !== a.label)) | |
| } | |
| aria-label={`Retirer ${a.label}`} | |
| > | |
| <span className="material-symbols-outlined text-[16px]">close</span> | |
| </button> | |
| </span> | |
| ))} | |
| </div> | |
| ) : null} | |
| </div> | |
| <div className="flex flex-col gap-sm"> | |
| <span className="font-label-sm text-label-sm text-on-surface-variant uppercase"> | |
| Emplacement sur la carte | |
| </span> | |
| <p className="font-label-sm text-label-sm text-on-surface-variant m-0"> | |
| Recherchez votre adresse ou cliquez sur la carte pour placer le repère de votre commerce. | |
| </p> | |
| <LocationMapPicker | |
| latitude={form.latitude} | |
| longitude={form.longitude} | |
| searchQuery={mapSearchHint} | |
| onLocationChange={handleLocationChange} | |
| /> | |
| </div> | |
| </form> | |
| </div> | |
| )} | |
| {currentStep === 2 && ( | |
| <div className="space-y-gutter"> | |
| <header className="text-center md:text-left"> | |
| <h1 className="font-headline-md text-headline-md text-on-background mb-sm m-0"> | |
| Nourrissez votre IA | |
| </h1> | |
| <p className="font-body-lg text-body-lg text-on-surface-variant m-0 max-w-xl"> | |
| Téléversez vos documents (PDF, Word) ou saisissez les informations clés pour que votre | |
| chatbot comprenne parfaitement votre activité. | |
| </p> | |
| </header> | |
| <div | |
| onDragEnter={handleDrag} | |
| onDragOver={handleDrag} | |
| onDragLeave={handleDrag} | |
| onDrop={handleDrop} | |
| onClick={() => fileInputRef.current?.click()} | |
| className={`border-2 border-dashed rounded-lg p-xl flex flex-col items-center justify-center transition-all duration-300 cursor-pointer ${ | |
| dragActive | |
| ? 'border-primary bg-surface-blue' | |
| : 'border-outline-variant hover:bg-surface-container-low' | |
| }`} | |
| > | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept=".pdf,.doc,.docx,.txt" | |
| className="hidden" | |
| onChange={handleFileSelect} | |
| /> | |
| <div className="w-16 h-16 rounded-full bg-surface-blue flex items-center justify-center mb-sm"> | |
| <span className="material-symbols-outlined text-primary text-[32px]">upload_file</span> | |
| </div> | |
| <p className="font-label-md text-label-md text-on-surface mb-xs m-0"> | |
| Glissez-déposez vos fichiers ici | |
| </p> | |
| <p className="font-label-sm text-label-sm text-on-surface-variant m-0">PDF, DOCX jusqu'à 20 Mo</p> | |
| <button | |
| type="button" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| fileInputRef.current?.click(); | |
| }} | |
| className="mt-md px-gutter py-3 bg-surface-container text-primary font-label-md text-label-md rounded-lg hover:bg-surface-container-high transition-colors border-0 cursor-pointer" | |
| > | |
| Parcourir les fichiers | |
| </button> | |
| </div> | |
| {uploadedFiles.length > 0 && ( | |
| <div className="space-y-2"> | |
| {uploadedFiles.map((f) => ( | |
| <div | |
| key={f.name} | |
| className="flex items-center justify-between p-sm bg-surface-container-low rounded-lg border border-hairline-border" | |
| > | |
| <div className="flex items-center gap-3"> | |
| <span className="material-symbols-outlined text-primary">description</span> | |
| <div> | |
| <p className="font-label-md text-label-md text-on-surface m-0">{f.name}</p> | |
| <p className="font-label-sm text-label-sm text-outline m-0"> | |
| {(f.size / 1024 / 1024).toFixed(2)} Mo | |
| </p> | |
| </div> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={removeDocument} | |
| className="text-error hover:bg-error-container p-2 rounded-full border-0 bg-transparent cursor-pointer" | |
| aria-label="Supprimer le fichier" | |
| > | |
| <span className="material-symbols-outlined text-[20px]">close</span> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| <div className="flex items-center gap-md"> | |
| <div className="flex-grow h-px bg-hairline-border" /> | |
| <span className="font-label-sm text-label-sm text-outline uppercase tracking-widest">ou</span> | |
| <div className="flex-grow h-px bg-hairline-border" /> | |
| </div> | |
| <label className="flex flex-col gap-xs"> | |
| <span className="font-label-md text-label-md text-on-surface"> | |
| Saisie manuelle des connaissances | |
| </span> | |
| <p className="font-label-sm text-label-sm text-on-surface-variant m-0 mb-xs"> | |
| Ce texte apparaît sur votre mini-site (section « Informations utiles ») et nourrit votre IA. | |
| </p> | |
| <textarea | |
| name="pasted_text" | |
| value={form.pasted_text} | |
| onChange={handleInputChange} | |
| disabled={!!file} | |
| className="w-full px-md py-sm border border-hairline-border rounded-lg font-body-md placeholder:text-outline-variant focus:ring-0 focus:border-primary transition-all resize-none disabled:opacity-50" | |
| placeholder="Services, tarifs, horaires, FAQ..." | |
| rows={6} | |
| /> | |
| </label> | |
| <div className="grid grid-cols-2 md:grid-cols-3 gap-gutter opacity-70 pt-sm"> | |
| <div className="flex items-center gap-2 justify-center"> | |
| <span className="material-symbols-outlined text-[20px]">verified_user</span> | |
| <span className="font-label-sm text-label-sm">Données sécurisées</span> | |
| </div> | |
| <div className="flex items-center gap-2 justify-center"> | |
| <span className="material-symbols-outlined text-[20px]">psychology</span> | |
| <span className="font-label-sm text-label-sm">IA souveraine</span> | |
| </div> | |
| <div className="flex items-center gap-2 justify-center col-span-2 md:col-span-1"> | |
| <span className="material-symbols-outlined text-[20px]">language</span> | |
| <span className="font-label-sm text-label-sm">Serveurs en Europe</span> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div className="flex items-center justify-between pt-md mt-lg border-t border-hairline-border"> | |
| <button | |
| type="button" | |
| onClick={prevStep} | |
| disabled={currentStep === 0} | |
| className={`flex items-center gap-2 font-label-md text-label-md text-on-surface-variant hover:text-primary transition-colors py-2 px-4 bg-transparent border-0 cursor-pointer ${ | |
| currentStep === 0 ? 'opacity-0 pointer-events-none' : '' | |
| }`} | |
| > | |
| <span className="material-symbols-outlined text-[20px]">arrow_back</span> | |
| Retour | |
| </button> | |
| <button | |
| type="button" | |
| onClick={nextStep} | |
| className="bg-primary text-on-primary px-8 py-3 rounded-lg font-label-md text-label-md hover:opacity-90 active:scale-95 transition-all border-0 cursor-pointer flex items-center gap-2" | |
| > | |
| {currentStep === STEPS_COUNT - 1 ? ( | |
| <> | |
| Créer mon chatbot | |
| <span className="material-symbols-outlined text-[18px]">rocket_launch</span> | |
| </> | |
| ) : ( | |
| 'Continuer' | |
| )} | |
| </button> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| </main> | |
| <footer className="bg-surface border-t border-hairline-border py-md shrink-0"> | |
| <div className="max-w-2xl mx-auto px-gutter flex justify-between items-center"> | |
| <span className="font-headline-sm italic text-on-surface"> | |
| <SahelLogo size={28} textClass="font-headline-sm italic text-on-surface" /> | |
| </span> | |
| <p className="font-label-sm text-label-sm text-on-surface-variant m-0">© 2026 Sahel.ai</p> | |
| </div> | |
| </footer> | |
| </div> | |
| ); | |
| } | |