/** * Oveyo PWA V8.1 — Composant PhotoForm * ======================================= * * Flow utilisateur : * Étape 1 : choix type photo (ticket / rayon / flyer) + enseigne (optionnel) * Étape 2 : upload (input file ou camera native) * Étape 3 : spinner "Analyse en cours…" (5-15 sec côté Claude Vision) * Étape 4 : affichage des prix extraits avec checkbox + édition * Étape 5 : confirmation → propositions créées (status pending) * * Quota : 5 photos/jour/user (vérifié côté serveur) */ const PhotoForm = ({ state, setState, setActiveTab, onLoginRequired, onBack, category }) => { const { ArrowLeft, Camera, Upload, Loader2, Check, X, Edit3, AlertCircle } = window.LucideReact || {}; const flash = window.OveyoUI?.useFlash ? window.OveyoUI.useFlash() : { show: () => {} }; const SAFE_TOP = { paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)' }; const [step, setStep] = React.useState('type'); // type → upload → parsing → review → done const [photoType, setPhotoType] = React.useState('ticket'); const [selectedBrand, setSelectedBrand] = React.useState(null); const [brands, setBrands] = React.useState([]); // V8.6.3-PHOTO-BRANCH-SELECT V8.6.3 : sélection de branche après enseigne + result du parse const [selectedBranch, setSelectedBranch] = React.useState(null); const [branches, setBranches] = React.useState([]); const [parseResult, setParseResult] = React.useState(null); // V8.6.5-PHOTO-BRANDS-FIX V8.6.5 : champ libre pour enseigne hors-liste const [brandFreeText, setBrandFreeText] = React.useState(''); const [useFreeText, setUseFreeText] = React.useState(false); const [quota, setQuota] = React.useState(null); const [photoFile, setPhotoFile] = React.useState(null); const [photoPreview, setPhotoPreview] = React.useState(null); const [photoId, setPhotoId] = React.useState(null); const [extractedItems, setExtractedItems] = React.useState([]); const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(null); const isLoggedIn = !!state.user?.session_token; const fileInputRef = React.useRef(null); // Charger quota + enseignes au montage React.useEffect(() => { if (!isLoggedIn) return; fetch('/api/pwa/photo/quota', { headers: { 'Authorization': 'Bearer ' + state.user.session_token }, }).then((r) => r.json()).then(setQuota).catch(() => {}); // V8.6.5-PHOTO-BRANDS-FIX V8.6.5 : fetch les enseignes ET stations séparément // (le backend ne renvoie category_slug que si on filtre par catégorie) Promise.all([ fetch('/api/pwa/brands?category=enseigne').then((r) => r.json()).catch(() => ({})), fetch('/api/pwa/brands?category=station').then((r) => r.json()).catch(() => ({})), ]).then(([enseignes, stations]) => { const all = [...(enseignes.brands || []), ...(stations.brands || [])]; setBrands(all); }); }, [isLoggedIn]); // V8.6.3-PHOTO-BRANCH-SELECT V8.6.3 : refresh branches quand enseigne change React.useEffect(() => { if (!selectedBrand?.id) { setBranches([]); setSelectedBranch(null); return; } fetch(`/api/pwa/branches?brand_id=${selectedBrand.id}`) .then((r) => r.json()) .then((d) => setBranches(d.branches || [])) .catch(() => setBranches([])); setSelectedBranch(null); // reset si enseigne change }, [selectedBrand?.id]); const onFileChosen = (file) => { if (!file) return; if (file.size > 8 * 1024 * 1024) { flash.show('error', 'Photo trop volumineuse (max 8 MB)'); return; } setPhotoFile(file); const reader = new FileReader(); reader.onload = (e) => setPhotoPreview(e.target.result); reader.readAsDataURL(file); setStep('upload'); }; const submitPhoto = async () => { if (!photoFile || !isLoggedIn) { if (!isLoggedIn) onLoginRequired?.(); return; } setStep('parsing'); setError(null); try { const fd = new FormData(); fd.append('photo', photoFile); const url = new URL('/api/pwa/photo/parse-prices', window.location.origin); url.searchParams.set('photo_type', photoType); if (selectedBrand?.id) url.searchParams.set('brand_id', selectedBrand.id); // V8.6.3-PHOTO-BRANCH-SELECT V8.6.3 : si branche sélectionnée, l'envoyer if (selectedBranch?.id) url.searchParams.set('branch_id', selectedBranch.id); // V8.6.5-PHOTO-BRANDS-FIX V8.6.5 : si l'user a saisi un nom libre, le passer if (useFreeText && brandFreeText.trim()) { url.searchParams.set('brand_name_freetext', brandFreeText.trim()); } const r = await fetch(url.toString(), { method: 'POST', headers: { 'Authorization': 'Bearer ' + state.user.session_token }, body: fd, }); if (r.status === 429) { const d = await r.json().catch(() => ({})); setError(d.detail || 'Limite quotidienne atteinte'); setStep('upload'); return; } if (!r.ok) { const d = await r.json().catch(() => ({})); setError(d.detail || 'Erreur d\'analyse'); setStep('upload'); return; } const data = await r.json(); setPhotoId(data.photo_id); // V8.6.3-PHOTO-BRANCH-SELECT V8.6.3 : stocker le résultat complet (pour matched_branch_id) setParseResult(data); // Préparer les items pour édition const items = (data.items || []).map((it, idx) => ({ ...it, idx, selected: true, // tout coché par défaut editing: false, // pas en mode édition })); setExtractedItems(items); if (items.length === 0) { setError('Aucun prix détecté sur cette photo. Essaie avec une photo plus nette.'); setStep('upload'); return; } setStep('review'); } catch (e) { setError('Erreur réseau'); setStep('upload'); } }; const toggleItem = (idx) => { setExtractedItems((prev) => prev.map((it) => it.idx === idx ? { ...it, selected: !it.selected } : it) ); }; const updateItemPrice = (idx, newPrice) => { const p = parseInt(newPrice, 10); if (isNaN(p) || p < 1) return; setExtractedItems((prev) => prev.map((it) => it.idx === idx ? { ...it, price: p } : it) ); }; const confirmProposals = async () => { if (!photoId) return; setSubmitting(true); try { const r = await fetch(`/api/pwa/photo/${photoId}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.user.session_token, }, body: JSON.stringify({ items: extractedItems.map((it) => ({ name_raw: it.name_raw, price: it.price, matched_product_id: it.matched_product_id, quantity: it.quantity, confidence: it.confidence, selected: it.selected, })), brand_id: selectedBrand?.id || null, // V8.6.3-PHOTO-BRANCH-SELECT V8.6.3 : envoie branch_id (sélectionné OU auto-détecté côté serveur) branch_id: selectedBranch?.id || (parseResult?.matched_branch_id) || null, }), }); const d = await r.json(); if (d.ok) { setStep('done'); flash.show('success', `✅ ${d.created_proposals} prix envoyés à validation`); } else { flash.show('error', 'Erreur lors de la confirmation'); } } catch (e) { flash.show('error', 'Erreur réseau'); } finally { setSubmitting(false); } }; // ======================================================== // RENDER // ======================================================== if (!isLoggedIn) { return (

Connecte-toi pour partager une photo de prix

); } return (

Photographier des prix

{step === 'type' && 'Étape 1/3 · Type'} {step === 'upload' && 'Étape 2/3 · Upload'} {step === 'parsing' && 'Analyse en cours…'} {step === 'review' && 'Étape 3/3 · Vérification'} {step === 'done' && 'Terminé !'}

{/* Quota indicator */} {quota && step !== 'done' && (
{quota.remaining > 0 ? `${quota.remaining}/${quota.limit} photos restantes aujourd'hui` : 'Limite quotidienne atteinte. Réessaie demain.'}
)} {/* === Étape 1 : Type de photo === */} {step === 'type' && (

Type de photo

{[ { slug: 'ticket', label: 'Ticket de caisse', icon: '🧾', desc: 'Auchan, Carrefour…' }, { slug: 'shelf', label: 'Étiquette de rayon', icon: '🏷️', desc: 'Photo prise en magasin' }, { slug: 'flyer', label: 'Flyer / Catalogue', icon: '📄', desc: 'Promotions papier' }, ].map((t) => ( ))}

Enseigne (optionnel)

{/* V8.6.5-PHOTO-BRANDS-FIX V8.6.5 : dropdown + option "Autre" qui ouvre un champ libre */} {useFreeText && ( setBrandFreeText(e.target.value)} placeholder="Ex : Lebon, Auchan Almadies, Petit Marché..." className="w-full mt-2 px-3 py-2.5 rounded-xl text-sm outline-none bg-white" style={{ border: '1px solid #EAEEF4' }} /> )} {/* V8.6.3-PHOTO-BRANCH-SELECT V8.6.3 : sélecteur de branche/succursale */} {selectedBrand && branches.length > 0 && ( <>

Succursale (optionnel)

Si tu laisses vide, on essaiera de la trouver à partir de l'adresse sur le ticket.

)}
)} {/* === Étape 2 : Upload === */} {step === 'upload' && (
onFileChosen(e.target.files[0])} style={{ display: 'none' }} /> {photoPreview ? (
Aperçu
) : ( )} {error && (
{error}
)}
)} {/* === Étape 3 : Parsing en cours === */} {step === 'parsing' && (
Analyse de la photo…
Claude IA extrait tous les prix de ta photo.
Cela prend 5 à 15 secondes.
{photoPreview && ( )}
)} {/* === Étape 4 : Vérification des prix extraits === */} {step === 'review' && (
✨ {extractedItems.length} prix détectés
Décoche ce qui n'est pas un prix produit. Modifie si besoin. Tout sera validé par notre équipe.
{extractedItems.map((it) => (
toggleItem(it.idx)} style={{ transform: 'scale(1.2)', accentColor: '#16A34A' }} />
{it.name_raw}
{it.matched_product_id ? ( ✓ Produit reconnu ) : ( ? À vérifier )} {it.quantity && · {it.quantity}} {it.confidence != null && ( · {Math.round(it.confidence * 100)}% )}
updateItemPrice(it.idx, e.target.value)} className="w-20 px-2 py-1 rounded-lg text-sm font-bold text-right outline-none" style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }} inputMode="numeric" /> F
))}
)} {/* === Étape 5 : Confirmation === */} {step === 'done' && (
Merci !
Tes prix seront validés par notre équipe sous 24-48h.
Une fois approuvés, ils apparaîtront dans le comparateur.
)}
); }; window.PhotoForm = PhotoForm;