/** * Oveyo PWA — Page Partager un prix * - Photo de l'étiquette (upload + analyse IA Claude vision côté serveur) * - Saisie manuelle avec sélection produit, prix, quartier libre, ville libre * - Géolocalisation auto pré-remplit quartier/ville * - Marquer comme abusif * - Envoi avec fallback offline (queue IndexedDB via service worker) * V3.1 : champs libres simples (plus de cascade ville/quartier) */ const Lu3 = window.LucideReact || {}; const { Card: C3, IconBubble: IB3, Spinner: Sp3, useFlash: uF } = window.OveyoUI; const { useCatalog: uCat3, useGeolocation: uGeo, api: api3, compressImage: cI } = window.OveyoState; const ReportPage = ({ state, setState, setActiveTab, onLoginRequired }) => { const { Camera, Plus, Check, ChevronRight, ShieldCheck, MapPin, X, Loader2, Upload, AlertTriangle, Sparkles, } = Lu3; const flash = uF(); const catalog = uCat3(); const products = catalog?.products || []; const neighborhoods = catalog?.neighborhoods || []; // V3.5 — Bug #2 : on lit aussi `error` pour pouvoir afficher un flash const { position, error: geoError, detect: detectGeo, loading: geoLoading } = uGeo(); const [step, setStep] = React.useState('start'); const [productId, setProductId] = React.useState(null); const [price, setPrice] = React.useState(''); const [shopName, setShopName] = React.useState(''); const [available, setAvailable] = React.useState(1); const [isAbuse, setIsAbuse] = React.useState(false); // V4.1 — quantité observée (texte libre simple) // Ex : "1 kg", "500 g", "2 L", "12 kg" (bouteille gaz), "6 unités" // Pré-rempli automatiquement avec products.unit du produit choisi. const [quantityLabel, setQuantityLabel] = React.useState(''); const [photoFile, setPhotoFile] = React.useState(null); const [photoPreview, setPhotoPreview] = React.useState(null); const [aiAnalyzing, setAiAnalyzing] = React.useState(false); const [aiSuggestion, setAiSuggestion] = React.useState(null); const [submitting, setSubmitting] = React.useState(false); // V3.1 — champs libres simples const [quartierInput, setQuartierInput] = React.useState(''); const [villeInput, setVilleInput] = React.useState('Dakar'); const photoInputRef = React.useRef(null); // Pré-remplir depuis le profil de l'utilisateur si disponible React.useEffect(() => { if (!quartierInput && state.user.neighborhood_slug && neighborhoods.length > 0) { const n = neighborhoods.find((x) => x.slug === state.user.neighborhood_slug); if (n) { setQuartierInput(n.name); if (n.city_name) setVilleInput(n.city_name); } } }, [state.user.neighborhood_slug, neighborhoods]); // Reverse-geocode quand position détectée React.useEffect(() => { if (!position) return; api3(`/api/pwa/geocode?lat=${position.lat}&lng=${position.lng}`) .then((d) => { if (d.neighborhood_name) setQuartierInput(d.neighborhood_name); if (d.city_name) setVilleInput(d.city_name); }) .catch(() => {}); }, [position]); // V3.5 — Bug #2 : afficher un flash visible si la géoloc échoue. React.useEffect(() => { if (geoError) flash.show('error', geoError); }, [geoError]); // V3.5 — Bug #8 : miroir de la position dans state.user.geolocation // pour que d'autres composants (ShopDetailSheet, etc.) puissent calculer // les distances sans avoir à re-déclencher useGeolocation. React.useEffect(() => { if (!position || typeof setState !== 'function') return; setState((s) => ({ ...s, user: { ...s.user, geolocation: { lat: position.lat, lng: position.lng, ts: Date.now() } }, })); }, [position]); const selectedProduct = products.find((p) => p.id === productId); // V4.1 — Pré-remplissage automatique de la quantité avec products.unit // dès qu'un produit est choisi. L'utilisateur peut écraser librement. React.useEffect(() => { if (!selectedProduct) return; if (selectedProduct.unit) { setQuantityLabel(selectedProduct.unit); } }, [selectedProduct?.id]); // Gestion photo const handlePhotoSelect = async (file) => { if (!file) return; const compressed = await cI(file, 1024, 0.85); setPhotoFile(compressed); setPhotoPreview(URL.createObjectURL(compressed)); setAiAnalyzing(true); try { const fd = new FormData(); fd.append('photo', compressed, 'etiquette.jpg'); const r = await fetch('/api/pwa/photo/analyze', { method: 'POST', body: fd }); const data = await r.json(); if (data.suggestion) { setAiSuggestion(data.suggestion); if (data.suggestion.product_id) setProductId(data.suggestion.product_id); if (data.suggestion.price) setPrice(String(data.suggestion.price)); flash.show('success', `IA détectée : ${data.suggestion.product_name || 'produit'} à ${data.suggestion.price || '?'} F`); } else { flash.show('info', "L'IA n'a pas pu lire l'étiquette, complète manuellement"); } setStep('manual'); } catch (e) { flash.show('error', "Erreur analyse photo, complète manuellement"); setStep('manual'); } finally { setAiAnalyzing(false); } }; // Soumission const submit = async () => { // V4.0 — Login obligatoire pour partager un prix. // Le partage anonyme reste autorisé techniquement côté backend // (pour la queue offline V3.5) mais l'UX impose le login d'abord. if (!state.user.phone) { flash.show('info', 'Connecte-toi avec WhatsApp pour partager ton prix'); if (typeof onLoginRequired === 'function') { onLoginRequired(); } return; } if (!productId) { flash.show('error', 'Choisis un produit'); return; } if (!price || isNaN(parseInt(price))) { flash.show('error', 'Prix invalide'); return; } const quartier = (quartierInput || '').trim(); const ville = (villeInput || '').trim(); if (quartier.length < 2) { flash.show('error', 'Renseigne ton quartier'); return; } if (ville.length < 2) { flash.show('error', 'Renseigne ta ville'); return; } setSubmitting(true); const payload = { product_id: productId, price: parseInt(price, 10), available: available, is_abuse: isAbuse ? 1 : 0, shop_name: shopName.trim() || null, reported_by: state.user.phone || 'pwa-anonymous', neighborhood_name: quartier, city_name: ville, // V4.1 — quantité libre (texte court, ex : "2 L", "500 g") quantity_label: (quantityLabel || '').trim() || null, }; try { if (photoFile && !aiSuggestion) { const fd = new FormData(); fd.append('photo', photoFile, 'observation.jpg'); const r = await fetch('/api/pwa/photo', { method: 'POST', body: fd }); const data = await r.json(); payload.photo_url = data.url; } else if (aiSuggestion?.photo_url) { payload.photo_url = aiSuggestion.photo_url; } const r = await fetch('/api/pwa/observation', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await r.json(); if (r.status === 202 && data.queued) { flash.show('info', '📡 Hors ligne — sera envoyé au retour du réseau'); setStep('success'); } else if (r.ok) { let msg = '✅ Prix partagé, jërëjëf !'; if (data.new_badges && data.new_badges.length > 0) { msg = `🏆 Nouveau badge : ${data.new_badges[0].name} !`; } flash.show('success', msg); setStep('success'); } else { flash.show('error', data.detail || "Erreur lors de l'envoi"); setSubmitting(false); return; } } catch (e) { flash.show('error', "Erreur lors de l'envoi"); } finally { setSubmitting(false); } }; const reset = () => { setStep('start'); setProductId(null); setPrice(''); setShopName(''); setAvailable(1); setIsAbuse(false); setPhotoFile(null); setPhotoPreview(null); setAiSuggestion(null); setQuantityLabel(''); // V4.1 }; // V4.0 — Vue CONNEXION REQUISE pour partager un prix // Lecture libre + écriture connectée : on bloque l'accès au formulaire // dès l'entrée si l'utilisateur n'est pas connecté. if (!state.user.phone) { return (

Connexion requise

Pour partager un prix, tu dois te connecter avec ton numéro WhatsApp.
C'est rapide, gratuit, et ça permet à la communauté de te faire confiance.

Pourquoi se connecter ?
Pour signaler un prix qui sera utile à des milliers de Sénégalais. Ton numéro reste privé : on n'affiche qu'un pseudonyme que tu choisis (« Aïssa de Grand Yoff »).

); } // Vue SUCCÈS if (step === 'success') { return (

Merci !

{/* V4.4 — message phase bêta dynamique Si BETA_PHASE=true côté backend (config.py), on affiche un message rassurant indiquant que l'équipe Oveyo vérifie. Sinon, message "vérifié par 2 voisins". */}

{catalog?.beta_phase ? ( <> Ton prix a été partagé. 🌱 Phase de lancement. La vérification est renforcée par l'équipe Oveyo pour garantir la fiabilité totale des premières données. ) : ( <>Ton prix a été partagé. Il sera vérifié par 2 voisins avant publication officielle. )}

); } // Vue PRINCIPALE return (

Partager un prix

Aide ton quartier en 10 secondes

{/* Bouton photo */} handlePhotoSelect(e.target.files?.[0])} /> {/* Suggestion IA */} {aiSuggestion && (
IA : {aiSuggestion.product_name || 'Produit'} {aiSuggestion.price && `à ${aiSuggestion.price} F`}
)}
ou complète
{/* Produit */}
{products.map((p) => ( ))}
{/* Prix + état */}
setPrice(e.target.value)} placeholder="600" className="mt-1.5 w-full p-3 rounded-xl font-display font-bold text-xl text-ink outline-none" style={{ background: '#F1F4F9' }} inputMode="numeric" />
{/* V4.1 — Quantité observée (texte libre court) Pré-rempli depuis products.unit ; l'utilisateur peut préciser s'il s'agit d'un format différent (ex: "2 L" au lieu de "1 L"). */}
setQuantityLabel(e.target.value)} placeholder={selectedProduct?.unit || 'Ex : 1 kg, 500 g, 2 L, 12 kg, 6 unités'} maxLength={30} className="mt-1.5 w-full p-3 rounded-xl text-sm outline-none" style={{ background: '#F1F4F9' }} />
Précise si tu observes un format différent du standard (ex : huile en bidon de 2 L, sucre en sachet de 500 g).
{/* Quartier — champ libre simple */}
setQuartierInput(e.target.value)} placeholder="Ex: Grand Yoff, Saly Plage..." maxLength={80} className="flex-1 p-3 rounded-xl text-sm outline-none" style={{ background: '#F1F4F9' }} />
{/* Ville — champ libre simple */}
setVilleInput(e.target.value)} placeholder="Ex: Dakar, Mbour, Thiès..." maxLength={80} className="mt-1.5 w-full p-3 rounded-xl text-sm outline-none" style={{ background: '#F1F4F9' }} />
{/* Boutique */}
setShopName(e.target.value)} placeholder="Ex: Boutique Modou" className="mt-1.5 w-full p-3 rounded-xl text-sm outline-none" style={{ background: '#F1F4F9' }} />
{/* Abus */} {/* Info verif — V4.4 message dynamique selon phase bêta */}
{catalog?.beta_phase ? ( 🌱 Phase de lancement : ton prix sera vérifié par l'équipe Oveyo avant publication, pour garantir la fiabilité. ) : ( Ton prix sera vérifié par 2 autres voisins avant publication officielle. )}
); }; window.OveyoPages = window.OveyoPages || {}; window.OveyoPages.ReportPage = ReportPage;