/** * Oveyo PWA V8.0.1 — Refonte design page-share * =============================================== * * Corrections V8.0.1 : * - Icônes Lucide blanches dans boxes plus petites (40×40, pas 56×56) * - Safe-area-inset-top pour ne pas être collé sous le status bar iOS * - Couleurs cohérentes avec page-home (navy pour bouton primary) * - Photos/picker : design uniforme bg-white + border #EAEEF4 * - Pas d'emoji rendus comme icônes (l'emoji 🏪 affiche un logo Apple coloré * qui ne correspond pas au DA Oveyo) → on utilise les icônes Lucide * - Espacement cohérent (px-5, gap-3) * - Plus d'aération sur les formulaires * * Composants exposés sur window : * - SharePicker * - QuartierForm, EnseigneForm, StationForm, PharmacieForm */ // ============================================================ // Helper : safe-area-top pour ne pas être collé au status bar // ============================================================ const SAFE_TOP = { paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)', }; // ============================================================ // SharePicker — page principale "Tu signales quoi ?" // ============================================================ const SharePicker = ({ state, setState, setActiveTab, onLoginRequired }) => { const { ArrowLeft, ChevronRight, Store, Fuel, Pill, ShoppingBasket, Camera } = window.LucideReact || {}; // Fix 0.10 — On NE force PLUS la modal Login au mount du SharePicker. // L'utilisateur peut explorer les 4 catégories sans être loggé. Les // sous-formulaires (Quartier/Enseigne/Station/Pharmacie) demanderont // l'auth au moment de submit. Cadenas visuel reste sur les boutons. const isLoggedIn = !!state?.user?.session_token; const [categories, setCategories] = React.useState([]); const [selectedCategory, setSelectedCategory] = React.useState(null); const [loading, setLoading] = React.useState(true); React.useEffect(() => { (async () => { try { const r = await fetch('/api/pwa/categories'); if (r.ok) { const d = await r.json(); setCategories(d.categories || []); } } catch (e) { /* silent */ } finally { setLoading(false); } })(); }, []); // Mapping slug → composant Lucide (icône blanche dans une box colorée) const ICON_FOR = { quartier: ShoppingBasket, enseigne: Store, station: Fuel, pharmacie: Pill, }; if (selectedCategory) { const props = { state, setState, setActiveTab, onLoginRequired, onBack: () => setSelectedCategory(null), category: selectedCategory, }; if (selectedCategory.slug === 'quartier' && window.QuartierForm) return ; if (selectedCategory.slug === 'enseigne' && window.EnseigneForm) return ; if (selectedCategory.slug === 'station' && window.StationForm) return ; if (selectedCategory.slug === 'pharmacie'&& window.PharmacieForm) return ; /* V8.1-PHOTO-ENABLED V8.1 : routing vers PhotoForm */ if (selectedCategory.slug === 'photo' && window.PhotoForm) return ; return (

Formulaire en cours de chargement…

); } return (
{/* V8.0.2-FIXES V8.0.2 : intitulé adouci + plus d'espacement */}

Que veux-tu partager ?

Choisis le type de prix que tu souhaites partager

{loading && (
Chargement…
)} {!loading && categories.length === 0 && (

Impossible de charger les catégories. Réessaie plus tard.

)}
{categories.map((cat) => { const Icon = ICON_FOR[cat.slug]; return ( ); })} {/* V8.1-PHOTO-ENABLED V8.1 : Photo activée — extract multi-produits via Claude Vision */}
Tes partages aident toute la communauté à payer le juste prix au Sénégal 🇸🇳
); }; // ============================================================ // Helpers communs aux 4 formulaires // ============================================================ const FormHeader = ({ onBack, icon: Icon, title, color, step, totalSteps, subtitle }) => { const { ArrowLeft } = window.LucideReact || {}; return ( <>
{Icon && }

{title}

{step && totalSteps && (

Étape {step}/{totalSteps}{subtitle ? ` · ${subtitle}` : ''}

)}
); }; // ============================================================ // QUARTIER — wrapper du ReportPage existant // ============================================================ const QuartierForm = ({ state, setState, setActiveTab, onLoginRequired, onBack, category }) => { const { ArrowLeft } = window.LucideReact || {}; React.useEffect(() => { setState((s) => ({ ...s, _share_context: { category_slug: 'quartier', branch_id: null }, })); }, []); const ReportPage = window.ReportPage; if (!ReportPage) { return
Formulaire en cours de chargement…
; } return (
); }; // ============================================================ // ENSEIGNE — 4 étapes : brand → branch → product → price // ============================================================ const EnseigneForm = ({ state, setState, setActiveTab, onLoginRequired, onBack, category }) => { const { ChevronRight, Store, Plus, MapPin, Navigation } = window.LucideReact || {}; const flash = window.OveyoUI?.useFlash ? window.OveyoUI.useFlash() : { show: () => {} }; const [step, setStep] = React.useState('brand'); const [brands, setBrands] = React.useState([]); const [branches, setBranches] = React.useState([]); const [products, setProducts] = React.useState([]); const [selectedBrand, setSelectedBrand] = React.useState(null); const [selectedBranch, setSelectedBranch] = React.useState(null); const [selectedProduct, setSelectedProduct] = React.useState(null); const [price, setPrice] = React.useState(''); const [available, setAvailable] = React.useState(true); const [submitting, setSubmitting] = React.useState(false); const [quantity, setQuantity] = React.useState(''); // Sprint 3 - quantite libre const [showGpsPrompt, setShowGpsPrompt] = React.useState(false); // V9.2.4 — Catalogue ouvert dans flow Enseigne const [pendingNewProduct, setPendingNewProduct] = React.useState(null); const [newProductName, setNewProductName] = React.useState(''); const [newProductCategory, setNewProductCategory] = React.useState('alimentaire'); const [gpsPromptDone, setGpsPromptDone] = React.useState(false); const [showCreateBranch, setShowCreateBranch] = React.useState(false); const [newBranchName, setNewBranchName] = React.useState(''); // V10.7.0 — État Localisation : quartier + GPS const [neighborhood, setNeighborhood] = React.useState(''); const [gettingGps, setGettingGps] = React.useState(false); // V10.7.0 — Pré-remplit le quartier depuis la succursale sélectionnée React.useEffect(() => { if (selectedBranch?.neighborhood_name) { setNeighborhood(selectedBranch.neighborhood_name); } }, [selectedBranch]); // V10.7.0 — Bouton GPS : récupère la géolocalisation const askGps = () => { if (!navigator.geolocation) { flash.show('error', 'Géolocalisation non supportée'); return; } setGettingGps(true); navigator.geolocation.getCurrentPosition( (pos) => { setState((s) => ({ ...s, user: { ...s.user, geolocation: { lat: pos.coords.latitude, lng: pos.coords.longitude, ts: Date.now() }, }, })); setGettingGps(false); flash.show('success', 'Position OK'); }, (err) => { setGettingGps(false); const msg = err.code === 1 ? 'Autorise la géolocalisation dans Réglages' : err.code === 2 ? 'Position indisponible' : 'Erreur de géolocalisation'; flash.show('error', msg); }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 0 } ); }; const isLoggedIn = !!state.user?.session_token; React.useEffect(() => { fetch('/api/pwa/brands?category=enseigne') .then((r) => r.json()) .then((d) => { // Filtrer les brands non pertinents pour le picker const filtered = (d.brands || []).filter((b) => !['sococim', 'peyrissac'].includes(b.slug) ); setBrands(filtered); }) .catch(() => setBrands([])); }, []); React.useEffect(() => { if (!selectedBrand) return; fetch(`/api/pwa/branches?brand_id=${selectedBrand.id}`) .then((r) => r.json()) .then((d) => setBranches(d.branches || [])) .catch(() => setBranches([])); }, [selectedBrand]); React.useEffect(() => { if (step !== 'product' || products.length > 0) return; fetch('/api/pwa/catalog') .then((r) => r.json()) .then((d) => { const allProducts = d.products || []; // V9.1.1 — Boutique : exclut energie sauf gaz 2,7kg et 6kg (vendus en quartier) const BOUTIQUE_GAZ_SLUGS = ['gaz-2-7kg', 'gaz']; const filtered = allProducts.filter(p => p.category_slug !== 'energie' || BOUTIQUE_GAZ_SLUGS.includes(p.slug) ); setProducts(filtered); }) .catch(() => setProducts([])); }, [step]); const createBranchInline = async () => { if (!newBranchName.trim()) return; if (!isLoggedIn) { onLoginRequired?.(); return; } try { const userGeo = state.user?.geolocation || null; const r = await fetch('/api/pwa/branches', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.user.session_token }, body: JSON.stringify({ brand_id: selectedBrand.id, name: newBranchName.trim(), lat: userGeo?.lat || null, lng: userGeo?.lng || null, }), }); const d = await r.json(); if (d.ok && d.branch_id) { const b = { id: d.branch_id, name: newBranchName.trim(), verified: false, source: 'crowdsource' }; setBranches((p) => [b, ...p]); setSelectedBranch(b); setShowCreateBranch(false); setNewBranchName(''); setStep('product'); flash.show('success', 'Succursale ajoutée — sera vérifiée'); } else { flash.show('error', 'Impossible de créer la succursale'); } } catch (e) { flash.show('error', 'Erreur réseau'); } }; const submitObservation = async () => { if (!isLoggedIn) { onLoginRequired?.(); return; } const priceInt = parseInt(price, 10); if (!priceInt || priceInt < 1) { flash.show('error', 'Saisis un prix valide'); return; } // Sprint 3 - GPS pressant : si pas de GPS et pas encore demande, ouvrir la sheet if (!state.user?.geolocation && !gpsPromptDone) { setShowGpsPrompt(true); return; } setSubmitting(true); try { const userGeo = state.user?.geolocation || null; // V10.7.0 — priorité au quartier saisi/auto-rempli, fallback sur user const ngName = neighborhood.trim() || state.user.neighborhood_slug || null; const payload = { product_id: selectedProduct?.id || null, price: priceInt, available: available ? 1 : 0, is_abuse: 0, shop_name: selectedBranch?.name || selectedBrand.name, reported_by: state.user.phone || 'pwa-anonymous', neighborhood_name: ngName, category_slug: 'enseigne', brand_id: selectedBrand?.id || null, branch_id: selectedBranch?.id || null, shop_lat: userGeo?.lat || null, shop_lng: userGeo?.lng || null, quantity_label: quantity.trim() || null, }; // V9.2.4 — Si nouveau produit : route vers /api/pwa/product-submission const isNewProduct = !!pendingNewProduct; const endpoint = isNewProduct ? '/api/pwa/product-submission' : '/api/pwa/observation'; const submissionPayload = isNewProduct ? { proposed_name: pendingNewProduct.name, proposed_category_slug: pendingNewProduct.category_slug, price: priceInt, shop_name_raw: selectedBranch?.name || selectedBrand.name, neighborhood_name_raw: ngName, brand_id: selectedBrand?.id || null, branch_id: selectedBranch?.id || null, shop_lat: userGeo?.lat ?? null, shop_lng: userGeo?.lng ?? null, } : payload; const r = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (state?.user?.session_token || ''), }, body: JSON.stringify(submissionPayload), }); if (r.ok) { if (isNewProduct) { flash.show('success', 'Produit soumis ! On l\'ajoute après vérification.'); } else { flash.show('success', `Prix ${selectedBrand.name} enregistré !`); } setActiveTab?.('home'); } else { // V8.6.1.2-ERR-DETAIL-ENS : lire detail backend pour message clair let msg = "Erreur lors de l'envoi"; try { const errBody = await r.json(); if (typeof errBody?.detail === 'string') msg = errBody.detail; else if (Array.isArray(errBody?.detail) && errBody.detail[0]?.msg) { const f = errBody.detail[0]; const field = Array.isArray(f.loc) ? f.loc[f.loc.length - 1] : ''; msg = field ? `${field}: ${f.msg}` : f.msg; } } catch {} flash.show('error', msg); } } catch (e) { flash.show('error', 'Erreur réseau'); } finally { setSubmitting(false); } }; const goBack = () => { // V9.2.4 — depuis 'price' avec nouveau produit, retour au form newProduct if (step === 'price' && pendingNewProduct) setStep('newProduct'); else if (step === 'price') setStep('product'); else if (step === 'newProduct') setStep('product'); else if (step === 'product') setStep('branch'); else if (step === 'branch') setStep('brand'); else onBack(); }; const stepNum = step === 'brand' ? 1 : step === 'branch' ? 2 : step === 'product' ? 3 : 4; return (
{/* Step 1 — pick brand : pas d'icône emoji moche, juste la couleur de la marque */} {step === 'brand' && (

Quelle enseigne ?

{brands.map((b) => ( ))}
)} {/* Step 2 — pick branch */} {step === 'branch' && (

Quelle succursale {selectedBrand.name} ?

{branches.map((br) => ( ))} {!showCreateBranch ? ( ) : (

Ex : {selectedBrand.name} Yoff

setNewBranchName(e.target.value)} placeholder={`Nom de la succursale ${selectedBrand.name}`} className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} autoFocus />
)}
)} {/* Step 3 — pick product */} {step === 'product' && (

Quel produit ?

{/* V9.2.4 — Bouton catalogue ouvert */}
{products .filter((p) => !['super', 'gasoil', 'essence_ord', 'essence_pirogue', 'petrole_lampant', 'gaz_2_7kg', 'gaz_9kg', 'gaz_12_5kg', 'gaz_38kg'].includes(p.slug)) .map((p) => ( ))}
)} {/* V9.2.4 — Step newProduct : saisie produit non listé (Enseigne) */} {step === 'newProduct' && (

Nouveau produit chez {selectedBrand?.name || 'l\'enseigne'}

Décris-le, on l'ajoute après vérification.

setNewProductName(e.target.value)} placeholder="Ex: Détergent OMO 1kg" maxLength={200} className="w-full mt-2 px-4 py-3 rounded-xl text-sm outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} autoFocus />
{[ { slug: 'alimentaire', label: '🛒 Alimentaire' }, { slug: 'legumes', label: '🥬 Légumes & Fruits' }, { slug: 'boulangerie', label: '🍞 Boulangerie' }, { slug: 'hygiene', label: '🧴 Hygiène' }, { slug: 'bebe', label: '👶 Bébé' }, { slug: 'medicaments', label: '💊 Médicaments' }, ].map((cat) => ( ))}
)} {/* Step 4 — saisir prix */} {step === 'price' && (selectedProduct || pendingNewProduct) && (

Prix {selectedBrand.name} pour {pendingNewProduct ? pendingNewProduct.name : selectedProduct.name_fr}

setPrice(e.target.value)} placeholder="Ex : 165" className="w-full mt-2 px-4 py-3 rounded-xl text-lg font-bold outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} autoFocus />
{/* Sprint 3 - Quantite (optionnel) */}
setQuantity(e.target.value)} placeholder="ex: 500g, 1L, 2 pièces" maxLength={30} className="w-full mt-2 px-4 py-3 rounded-xl outline-none text-sm" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} />
{/* V10.7.0 — Bloc Localisation : quartier (auto-rempli depuis succursale ou GPS) */}
setNeighborhood(e.target.value)} placeholder="Ex: Almadies, Plateau, Mermoz…" className="flex-1 px-4 py-3 rounded-xl outline-none text-sm" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} />

{state.user?.geolocation ? '✓ Position partagée — la carte affichera ce prix' : '💡 Active le GPS pour que ton prix apparaisse sur la carte'}

)} {/* Sprint 3 - GPS pressant */} {window.OveyoSheets?.GpsPrompt && ( setShowGpsPrompt(false)} onActivated={(coords) => { setState((s) => ({ ...s, user: { ...s.user, geolocation: { ...coords, ts: Date.now() } } })); setShowGpsPrompt(false); setGpsPromptDone(true); setTimeout(() => submitObservation(), 100); }} onContinueWithoutGps={() => { setShowGpsPrompt(false); setGpsPromptDone(true); setTimeout(() => submitObservation(), 100); }} /> )}
); }; // ============================================================ // STATION — flow station avec auto-check CRSE // ============================================================ const StationForm = ({ state, setState, setActiveTab, onLoginRequired, onBack, category }) => { const { ChevronRight, Fuel, Plus, AlertTriangle, Check, MapPin, Navigation } = window.LucideReact || {}; const flash = window.OveyoUI?.useFlash ? window.OveyoUI.useFlash() : { show: () => {} }; const [step, setStep] = React.useState('brand'); const [brands, setBrands] = React.useState([]); const [branches, setBranches] = React.useState([]); const [stationProducts, setStationProducts] = React.useState([]); // V9.2.5 — Catalogue ouvert dans flow Station (catégorie auto: 'energie') const [pendingNewProduct, setPendingNewProduct] = React.useState(null); const [newProductName, setNewProductName] = React.useState(''); const [selectedBrand, setSelectedBrand] = React.useState(null); const [selectedBranch, setSelectedBranch] = React.useState(null); const [selectedProduct, setSelectedProduct] = React.useState(null); const [price, setPrice] = React.useState(''); const [crseCheck, setCrseCheck] = React.useState(null); const [submitting, setSubmitting] = React.useState(false); const [showGpsPrompt, setShowGpsPrompt] = React.useState(false); // Sprint 3 - GPS const [gpsPromptDone, setGpsPromptDone] = React.useState(false); // Sprint 3 - flag const [available, setAvailable] = React.useState(true); const [newBranchName, setNewBranchName] = React.useState(''); const [showCreateBranch, setShowCreateBranch] = React.useState(false); // V10.7.0 — Localisation const [neighborhood, setNeighborhood] = React.useState(''); const [gettingGps, setGettingGps] = React.useState(false); React.useEffect(() => { if (selectedBranch?.neighborhood_name) { setNeighborhood(selectedBranch.neighborhood_name); } }, [selectedBranch]); const askGps = () => { if (!navigator.geolocation) { flash.show('error', 'Géolocalisation non supportée'); return; } setGettingGps(true); navigator.geolocation.getCurrentPosition( (pos) => { setState((s) => ({ ...s, user: { ...s.user, geolocation: { lat: pos.coords.latitude, lng: pos.coords.longitude, ts: Date.now() }, }, })); setGettingGps(false); flash.show('success', 'Position OK'); }, (err) => { setGettingGps(false); const msg = err.code === 1 ? 'Autorise la géolocalisation dans Réglages' : err.code === 2 ? 'Position indisponible' : 'Erreur de géolocalisation'; flash.show('error', msg); }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 0 } ); }; const isLoggedIn = !!state.user?.session_token; React.useEffect(() => { fetch('/api/pwa/brands?category=station') .then((r) => r.json()) .then((d) => setBrands(d.brands || [])) .catch(() => setBrands([])); }, []); React.useEffect(() => { if (!selectedBrand) return; fetch(`/api/pwa/branches?brand_id=${selectedBrand.id}`) .then((r) => r.json()) .then((d) => setBranches(d.branches || [])) .catch(() => setBranches([])); }, [selectedBrand]); React.useEffect(() => { if (step !== 'product' || stationProducts.length > 0) return; fetch('/api/pwa/crse/active') .then((r) => r.json()) .then((d) => setStationProducts(d.prices || [])) .catch(() => setStationProducts([])); }, [step]); React.useEffect(() => { if (!selectedProduct || !price || parseInt(price, 10) < 1) { setCrseCheck(null); return; } const t = setTimeout(() => { fetch('/api/pwa/crse/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ product_slug: selectedProduct.product_slug, observed_price: parseInt(price, 10), }), }) .then((r) => r.json()) .then((d) => setCrseCheck(d)) .catch(() => setCrseCheck(null)); }, 400); return () => clearTimeout(t); }, [price, selectedProduct]); const createBranchInline = async () => { if (!newBranchName.trim()) return; if (!isLoggedIn) { onLoginRequired?.(); return; } try { const userGeo = state.user?.geolocation || null; const r = await fetch('/api/pwa/branches', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.user.session_token }, body: JSON.stringify({ brand_id: selectedBrand.id, name: newBranchName.trim(), lat: userGeo?.lat || null, lng: userGeo?.lng || null, }), }); const d = await r.json(); if (d.ok && d.branch_id) { const b = { id: d.branch_id, name: newBranchName.trim(), verified: false }; setBranches((p) => [b, ...p]); setSelectedBranch(b); setShowCreateBranch(false); setNewBranchName(''); setStep('product'); } } catch (e) { flash.show('error', 'Erreur réseau'); } }; const submitObservation = async () => { if (!isLoggedIn) { onLoginRequired?.(); return; } const priceInt = parseInt(price, 10); if (!priceInt || priceInt < 1) { flash.show('error', 'Saisis un prix valide'); return; } // Sprint 3 - GPS pressant if (!state.user?.geolocation && !gpsPromptDone) { setShowGpsPrompt(true); return; } setSubmitting(true); try { const userGeo = state.user?.geolocation || null; const isAbuse = crseCheck && crseCheck.compliance === 'above' ? 1 : 0; // Sprint 3 - Conformite CRSE: 1=conforme, 0=non conforme, null=pas de prix CRSE let crseCompliant = null; if (crseCheck && crseCheck.has_crse) { crseCompliant = crseCheck.compliance === 'above' ? 0 : 1; } // V10.7.0 — Quartier saisi prioritaire const ngName = neighborhood.trim() || state.user.neighborhood_slug || null; const payload = { product_id: selectedProduct?.product_id || null, price: priceInt, available: available ? 1 : 0, is_abuse: isAbuse, crse_compliant: crseCompliant, shop_name: selectedBranch?.name || selectedBrand.name, reported_by: state.user.phone || 'pwa-anonymous', neighborhood_name: ngName, category_slug: 'station', brand_id: selectedBrand?.id || null, branch_id: selectedBranch?.id || null, shop_lat: userGeo?.lat || null, shop_lng: userGeo?.lng || null, }; // V9.2.5 — Si nouveau produit Station : route vers /api/pwa/product-submission const isNewProduct = !!pendingNewProduct; const endpoint = isNewProduct ? '/api/pwa/product-submission' : '/api/pwa/observation'; const submissionPayload = isNewProduct ? { proposed_name: pendingNewProduct.name, proposed_category_slug: 'energie', price: priceInt, shop_name_raw: selectedBranch?.name || selectedBrand.name, neighborhood_name_raw: ngName, brand_id: selectedBrand?.id || null, branch_id: selectedBranch?.id || null, shop_lat: userGeo?.lat ?? null, shop_lng: userGeo?.lng ?? null, } : payload; const r = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (state?.user?.session_token || ''), }, body: JSON.stringify(submissionPayload), }); if (r.ok) { if (isNewProduct) { flash.show('success', 'Produit soumis ! On l\'ajoute après vérification.'); } else { flash.show('success', `Prix ${selectedBrand.name} enregistré !`); } setActiveTab?.('home'); } else { // V8.6.1.2-ERR-DETAIL-STA : lire detail backend pour message clair let msg = "Erreur lors de l'envoi"; try { const errBody = await r.json(); if (typeof errBody?.detail === 'string') msg = errBody.detail; else if (Array.isArray(errBody?.detail) && errBody.detail[0]?.msg) { const f = errBody.detail[0]; const field = Array.isArray(f.loc) ? f.loc[f.loc.length - 1] : ''; msg = field ? `${field}: ${f.msg}` : f.msg; } } catch {} flash.show('error', msg); } } catch (e) { flash.show('error', 'Erreur réseau'); } finally { setSubmitting(false); } }; const goBack = () => { // V9.2.4 — depuis 'price' avec nouveau produit, retour au form newProduct if (step === 'price' && pendingNewProduct) setStep('newProduct'); else if (step === 'price') setStep('product'); else if (step === 'newProduct') setStep('product'); else if (step === 'product') setStep('branch'); else if (step === 'branch') setStep('brand'); else onBack(); }; const stepNum = step === 'brand' ? 1 : step === 'branch' ? 2 : step === 'product' ? 3 : 4; return (
{step === 'brand' && (

Quelle station ?

{brands.map((b) => ( ))}
)} {step === 'branch' && (

Quelle station {selectedBrand.name} ?

{branches.map((br) => ( ))} {!showCreateBranch ? ( ) : (
setNewBranchName(e.target.value)} placeholder={`Ex : ${selectedBrand.name} Yoff`} className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} autoFocus />
)}
)} {step === 'product' && (

Quel produit ?

{/* V9.2.5 — Bouton catalogue ouvert (Station) */} {stationProducts.map((p) => ( ))}
)} {/* V9.2.5 — Step newProduct : saisie produit non listé (Station, catégorie forcée 'energie') */} {step === 'newProduct' && (

Nouveau produit chez {selectedBrand?.name || 'la station'}

Carburant, gaz, additif… Décris-le, on l'ajoute après vérification.

setNewProductName(e.target.value)} placeholder="Ex: Gasoil pirogue 5L" maxLength={200} className="w-full mt-2 px-4 py-3 rounded-xl text-sm outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} autoFocus />
)} {step === 'price' && (selectedProduct || pendingNewProduct) && (

Prix observé pour {pendingNewProduct ? pendingNewProduct.name : selectedProduct.product_name}

setPrice(e.target.value)} placeholder={selectedProduct?.price ? `CRSE : ${selectedProduct.price} F` : 'Ex: 800'} className="w-full mt-2 px-4 py-3 rounded-xl text-lg font-bold outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} autoFocus />
{crseCheck && crseCheck.has_crse && (
{crseCheck.compliance === 'above' ? ( ) : crseCheck.compliance === 'below' ? ( ) : ( )}
{crseCheck.message}
)} {/* V10.7.0 — Bloc Localisation : quartier + GPS pour la station */}
setNeighborhood(e.target.value)} placeholder="Ex: Almadies, Plateau, Mermoz…" className="flex-1 px-4 py-3 rounded-xl outline-none text-sm" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} />

{state.user?.geolocation ? '✓ Position partagée' : '💡 Active le GPS pour la carte'}

)} {/* Sprint 3 - GPS pressant */} {window.OveyoSheets?.GpsPrompt && ( setShowGpsPrompt(false)} onActivated={(coords) => { setState((s) => ({ ...s, user: { ...s.user, geolocation: { ...coords, ts: Date.now() } } })); setShowGpsPrompt(false); setGpsPromptDone(true); setTimeout(() => submitObservation(), 100); }} onContinueWithoutGps={() => { setShowGpsPrompt(false); setGpsPromptDone(true); setTimeout(() => submitObservation(), 100); }} /> )}
); }; // ============================================================ // PHARMACIE — formulaire simple, design cohérent // ============================================================ const PharmacieForm = ({ state, setState, setActiveTab, onLoginRequired, onBack, category }) => { const { Pill } = window.LucideReact || {}; const flash = window.OveyoUI?.useFlash ? window.OveyoUI.useFlash() : { show: () => {} }; const [pharmacyName, setPharmacyName] = React.useState(''); const [medName, setMedName] = React.useState(''); const [price, setPrice] = React.useState(''); const [available, setAvailable] = React.useState(true); const [submitting, setSubmitting] = React.useState(false); // V8.6.1.2-PHARMACY-NEIGHBORHOOD : champ Quartier (texte libre) const [neighborhoodInput, setNeighborhoodInput] = React.useState(state?.user?.neighborhood_slug || ''); // V10.7.0 — GPS button state const [gettingGpsPharma, setGettingGpsPharma] = React.useState(false); const askGpsPharma = () => { if (!navigator.geolocation) { flash.show('error', 'Géolocalisation non supportée'); return; } setGettingGpsPharma(true); navigator.geolocation.getCurrentPosition( (pos) => { setState((s) => ({ ...s, user: { ...s.user, geolocation: { lat: pos.coords.latitude, lng: pos.coords.longitude, ts: Date.now() }, }, })); setGettingGpsPharma(false); flash.show('success', 'Position OK'); }, (err) => { setGettingGpsPharma(false); const msg = err.code === 1 ? 'Autorise la géolocalisation dans Réglages' : err.code === 2 ? 'Position indisponible' : 'Erreur de géolocalisation'; flash.show('error', msg); }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 0 } ); }; const isLoggedIn = !!state.user?.session_token; const submit = async () => { if (!isLoggedIn) { onLoginRequired?.(); return; } if (!pharmacyName.trim() || !medName.trim() || !parseInt(price, 10)) { flash.show('error', 'Remplis tous les champs'); return; } setSubmitting(true); try { const userGeo = state.user?.geolocation || null; // V8.6.1.2-PHARMACY-NEIGHBORHOOD : quartier saisi prioritaire sur profil const ngName = neighborhoodInput && neighborhoodInput.trim() ? neighborhoodInput.trim() : (state.user?.neighborhood_slug || null); const payload = { product_id: 118, // Sprint 2 - produit 'Medicament' generique (slug: medicament-generique). Le vrai nom du medicament est dans pharmacy_drug. price: parseInt(price, 10), available: available ? 1 : 0, is_abuse: 0, shop_name: `${pharmacyName.trim()} — ${medName.trim()}`, reported_by: state.user.phone || 'pwa-anonymous', neighborhood_name: ngName, category_slug: 'pharmacie', shop_lat: userGeo?.lat || null, shop_lng: userGeo?.lng || null, pharmacy_drug: medName.trim(), }; const r = await fetch('/api/pwa/observation', { method: 'POST', // V8.6.2-FETCH-AUTH V8.6.2 : ajoute Bearer token (backend exige session) headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (state?.user?.session_token || ''), }, body: JSON.stringify(payload), }); if (r.ok) { flash.show('success', 'Prix médicament enregistré'); setActiveTab?.('home'); } else { // V8.6.1.2-ERR-DETAIL-PHA : lire detail backend pour message clair let msg = "Erreur lors de l'envoi"; try { const errBody = await r.json(); if (typeof errBody?.detail === 'string') msg = errBody.detail; else if (Array.isArray(errBody?.detail) && errBody.detail[0]?.msg) { const f = errBody.detail[0]; const field = Array.isArray(f.loc) ? f.loc[f.loc.length - 1] : ''; msg = field ? `${field}: ${f.msg}` : f.msg; } } catch {} flash.show('error', msg); } } catch (e) { flash.show('error', 'Erreur réseau'); } finally { setSubmitting(false); } }; return (

Partage un prix de médicament

setPharmacyName(e.target.value)} placeholder="Ex : Pharmacie Eden" className="w-full mt-2 px-3 py-2.5 rounded-lg text-sm outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} />
setNeighborhoodInput(e.target.value)} placeholder="Ex : Mermoz, Yoff, Saly..." className="flex-1 px-3 py-2.5 rounded-lg text-sm outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} /> {/* V10.7.0 — Bouton GPS */}
setMedName(e.target.value)} placeholder="Ex : Doliprane 500mg" className="w-full mt-2 px-3 py-2.5 rounded-lg text-sm outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} />
setPrice(e.target.value)} placeholder="Ex : 1500" className="w-full mt-2 px-3 py-2.5 rounded-lg text-sm font-bold outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} />
); }; // Export window.SharePicker = SharePicker; window.QuartierForm = QuartierForm; window.EnseigneForm = EnseigneForm; window.StationForm = StationForm; window.PharmacieForm = PharmacieForm;