// ── 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
✕ Chiudi
);
}
// ── 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 (
);
}
// ── 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 */}
setShowScanner(true)}
className="px-3 py-2 border border-gray-200 text-gray-600 text-xs font-semibold rounded-lg active:bg-gray-100 whitespace-nowrap"
>
📷 Scansiona
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]"
/>
doSearch(query)}
disabled={loading}
className="px-3 py-2 bg-[#2d7a3a] text-white text-sm font-semibold rounded-lg disabled:opacity-60 active:bg-[#236130] whitespace-nowrap"
>
{loading ? "…" : "Cerca"}
{!mongoOk && (
Database prodotti offline — scansiona o inserisci un codice a barre
per cercare.
)}
{/* Advanced filters */}
setShowAdvanced((v) => !v)}
className="text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1"
>
{showAdvanced ? "▾" : "▸"}
Filtri avanzati
{(filterBrand || filterCategory) && (
{[filterBrand, filterCategory].filter(Boolean).length}
)}
{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.
pick({ barcode: weightedInfo.itemCode })}
disabled={saving}
className="px-3 py-1.5 bg-[#2d7a3a] text-white text-xs font-bold rounded-lg disabled:opacity-60 active:bg-[#236130]"
>
{saving
? "…"
: `Salva come articolo a peso ${weightedInfo.itemCode}`}
)}
{results && results.length === 0 && !loading && !weightedInfo && (
Nessun risultato.
{looksLikeBarcode(query) && (
pick({ barcode: query.trim() })}
disabled={saving}
className="px-3 py-1.5 bg-gray-700 text-white text-xs font-semibold rounded-lg disabled:opacity-60 active:bg-gray-900 whitespace-nowrap"
>
{saving ? "…" : `Salva codice a barre ${query.trim()}`}
)}
)}
{results &&
results.map((p, i) => (
{
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.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`}
openDebug(p)}
className="px-2 py-1.5 border border-gray-200 text-gray-400 text-xs font-mono font-semibold rounded-lg active:bg-gray-100"
>
{"{}"}
pick(p)}
disabled={saving}
className="px-3 py-1.5 bg-[#2d7a3a] text-white text-xs font-bold rounded-lg disabled:opacity-60 active:bg-[#236130]"
>
{saving ? "…" : "Usa questo"}
))}
{debugProduct && (
setDebugProduct(null)}
>
e.stopPropagation()}
>
{debugProduct.product_name}
setDebugProduct(null)}
className="text-gray-400 text-lg leading-none ml-2"
>
✕
{debugProduct._loading ? (
Caricamento…
) : (
)}
)}
);
}