// ── Barcode scanner overlay ──────────────────────────────────────────────────── function BarcodeScanner({ onScan, onClose }) { const videoRef = React.useRef(null); React.useEffect(() => { const reader = new ZXing.BrowserMultiFormatReader(); const constraints = { video: { facingMode: { ideal: "environment" } } }; reader .decodeFromConstraints(constraints, videoRef.current, (result, err) => { if (result) { reader.reset(); onScan(result.getText()); } }) .catch((e) => console.error("[scanner]", e)); return () => reader.reset(); }, []); return (

Punta la fotocamera sul codice a barre

); } // ── Autocomplete input ──────────────────────────────────────────────────────── function AutocompleteInput({ placeholder, value, onChange, field }) { const [suggestions, setSuggestions] = React.useState([]); const [open, setOpen] = React.useState(false); const [dropdownStyle, setDropdownStyle] = React.useState({}); const inputRef = React.useRef(null); const debounceRef = React.useRef(null); function handleChange(e) { const v = e.target.value; onChange(v); clearTimeout(debounceRef.current); if (v.length < 2) { setSuggestions([]); setOpen(false); return; } debounceRef.current = setTimeout(async () => { try { const r = await fetch( `/api/suggest-off?field=${field}&q=${encodeURIComponent(v)}`, ); const d = await r.json(); setSuggestions(d.suggestions || []); if (inputRef.current) { const rect = inputRef.current.getBoundingClientRect(); setDropdownStyle({ position: "fixed", top: rect.bottom + 4, left: rect.left, width: rect.width, zIndex: 9999, }); } setOpen(true); } catch {} }, 250); } function pick(s) { onChange(s); setSuggestions([]); setOpen(false); } return (
setTimeout(() => setOpen(false), 150)} placeholder={placeholder} className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#2d7a3a]/30 focus:border-[#2d7a3a]" /> {open && suggestions.length > 0 && ReactDOM.createPortal( , document.body, )}
); } // ── Search widget ───────────────────────────────────────────────────────────── function SearchWidget({ itemName, chain, receiptId, onSaved, onCancel, mongoOk, }) { const [query, setQuery] = React.useState(""); const [results, setResults] = React.useState(null); const [loading, setLoading] = React.useState(false); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const [debugProduct, setDebugProduct] = React.useState(null); const [debugLoading, setDebugLoading] = React.useState(false); const [showScanner, setShowScanner] = React.useState(false); const [showAdvanced, setShowAdvanced] = React.useState(false); const [filterBrand, setFilterBrand] = React.useState(""); const [filterCategory, setFilterCategory] = React.useState(""); const [barcodeWarning, setBarcodeWarning] = React.useState(null); const [weightedInfo, setWeightedInfo] = React.useState(null); const lastRawScan = React.useRef(null); const _BARCODE_TYPE_LABELS = { weighted: "Articolo pesato/prezzato in negozio — nessun prodotto globale esiste per questo codice", periodical: "Questo è una rivista/periodico (ISSN), non un prodotto alimentare", book: "Questo è un libro (ISBN), non un prodotto alimentare", coupon: "Questo è un codice coupon, non un prodotto", }; function looksLikeBarcode(q) { return /^\d{7,14}$/.test(q.trim()); } async function fetchBarcodeInfo(code) { const r = await fetch( `/api/parse-barcode?code=${encodeURIComponent(code.trim())}`, ); return r.json(); } React.useEffect(() => { if (debugProduct) { document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = ""; }; } }, [debugProduct]); async function openDebug(p) { setDebugLoading(true); setDebugProduct({ product_name: p.product_name, _loading: true }); try { const r = await fetch( `/api/product-raw/${encodeURIComponent(p.barcode)}`, ); setDebugProduct(await r.json()); } catch (e) { setDebugProduct(p); } finally { setDebugLoading(false); } } React.useEffect(() => { fetch( `/api/clean-name?name=${encodeURIComponent(itemName)}&chain=${encodeURIComponent(chain)}`, ) .then((r) => r.json()) .then((d) => setQuery(d.cleaned || itemName)) .catch(() => setQuery(itemName)); }, []); function handleScan(code) { setShowScanner(false); lastRawScan.current = code; setQuery(code); doSearch(code); } async function doSearch(q) { const isBarcode = q.trim() && looksLikeBarcode(q); if (!mongoOk && !isBarcode) return; const hasFilters = filterBrand || filterCategory; if (!q.trim() && !hasFilters) return; setLoading(true); setError(null); setBarcodeWarning(null); setWeightedInfo(null); try { let url; if (q.trim() && looksLikeBarcode(q)) { lastRawScan.current = q.trim(); const info = await fetchBarcodeInfo(q); if (!info.ok) { setBarcodeWarning(info.error); setResults([]); setLoading(false); return; } setQuery(info.normalized); if (info.type === "weighted") { setBarcodeWarning(_BARCODE_TYPE_LABELS.weighted); setWeightedInfo({ itemCode: info.item_code, embeddedValue: info.embedded_value, fullCode: info.normalized, }); setResults([]); setLoading(false); return; } if (info.type !== "product") setBarcodeWarning(_BARCODE_TYPE_LABELS[info.type]); url = `/api/search-off?barcode=${encodeURIComponent(info.normalized)}`; } else { const params = new URLSearchParams({ chain }); if (q.trim()) params.set("q", q.trim()); if (filterBrand) params.set("brand", filterBrand); if (filterCategory) params.set("category", filterCategory); url = `/api/search-off?${params}`; } setResults((await (await fetch(url)).json()).results || []); } catch (e) { setError(String(e)); } finally { setLoading(false); } } async function pick(product) { setSaving(true); try { await fetch("/api/match", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chain, item_name: itemName, barcode: product.barcode, raw_barcode: lastRawScan.current || product.barcode, receipt_id: receiptId || null, }), }); onSaved(); } catch (e) { setError(String(e)); setSaving(false); } } return (
{showScanner && ( setShowScanner(false)} /> )} {/* Search row */}
setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && doSearch(query)} placeholder="Cerca per nome o codice a barre…" className="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#2d7a3a]/30 focus:border-[#2d7a3a]" />
{!mongoOk && (

Database prodotti offline — scansiona o inserisci un codice a barre per cercare.

)} {/* Advanced filters */}
{showAdvanced && (
)}
{error &&

Errore: {error}

} {barcodeWarning && (
{barcodeWarning}
)} {weightedInfo && (
Articolo pesato/prezzato
Codice articolo:{" "} {weightedInfo.itemCode} {weightedInfo.embeddedValue != null && ( Valore:{" "} {weightedInfo.embeddedValue} (centesimi prezzo o grammi) )}

Questo è un codice in negozio per {chain || "questa catena"}. Verrà raggruppato con altri acquisti dello stesso articolo indipendentemente da peso/prezzo.

)} {results && results.length === 0 && !loading && !weightedInfo && (

Nessun risultato.

{looksLikeBarcode(query) && ( )}
)} {results && results.map((p, i) => (
{p.product_name} { e.target.onerror = null; e.target.src = PLACEHOLDER_IMG; }} className="w-12 h-12 object-contain rounded-lg border border-gray-100 bg-gray-50 flex-shrink-0" />
{p.barcode ? ( {p.product_name} ) : ( p.product_name )}
{p.brands && ( {p.brands} )} {p.quantity && ( {p.quantity} )}
{p.energy_kcal != null && `${Math.round(p.energy_kcal)} kcal`} {p.fat != null && ` · fat ${fmt(p.fat)}g`} {p.proteins != null && ` · prot ${fmt(p.proteins)}g`}
))} {debugProduct && (
setDebugProduct(null)} >
e.stopPropagation()} >
{debugProduct.product_name}
{debugProduct._loading ? (

Caricamento…

) : (
)}
)}
); }