// ── Shared utilities ──────────────────────────────────────────────────────────
const PAYMENT_SUGGESTIONS = ["Pagamento Contante", "Pagamento Elettronico"];
function todayDMY() {
const d = new Date();
return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`;
}
function nowHM() {
const d = new Date();
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function useStoreNames() {
const [storeNames, setStoreNames] = React.useState([]);
React.useEffect(() => {
fetch("/api/receipts")
.then((r) => r.json())
.then(({ receipts }) => {
const names = Array.from(
new Set(receipts.map((r) => r.store_name).filter(Boolean)),
).sort();
setStoreNames(names);
})
.catch(() => {});
}, []);
return storeNames;
}
function evalPrice(raw) {
const cleaned = String(raw)
.replace(/,/g, ".")
.replace(/[^0-9+\-*/.()\s]/g, "");
if (!cleaned.trim()) return null;
try {
// eslint-disable-next-line no-new-func
const result = Function('"use strict"; return (' + cleaned + ")")();
if (typeof result === "number" && isFinite(result))
return Math.round(result * 100) / 100;
} catch (_) {}
return null;
}
// ── Foldable JSON viewer ──────────────────────────────────────────────────────
function JsonNode({ value, depth = 0 }) {
const [open, setOpen] = React.useState(depth < 2);
if (value === null) return null ;
if (typeof value === "boolean")
return {String(value)} ;
if (typeof value === "number")
return {value} ;
if (typeof value === "string")
return "{value}" ;
const isArray = Array.isArray(value);
const entries = isArray
? value.map((v, i) => [i, v])
: Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
const [open0, close0] = isArray ? ["[", "]"] : ["{", "}"];
if (entries.length === 0)
return (
{open0}
{close0}
);
const preview = isArray
? `${open0} ${entries.length} items ${close0}`
: `${open0} ${entries
.slice(0, 3)
.map(([k]) => k)
.join(", ")}${entries.length > 3 ? ", …" : ""} ${close0}`;
return (
setOpen((o) => !o)}
className="text-gray-400 hover:text-gray-700 font-mono text-xs mr-0.5 select-none"
>
{open ? "▾" : "▸"}
{!open ? (
setOpen(true)}
>
{preview}
) : (
<>
{open0}
{entries.map(([k, v], i) => (
{!isArray && (
"{k}"
)}
{!isArray && : }
{i < entries.length - 1 && (
,
)}
))}
{close0}
>
)}
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const CATEGORY_ICON = {
Beverages: "🥤",
"Dairy & Eggs": "🥛",
"Meat & Fish": "🥩",
"Bakery & Cereals": "🥖",
"Fruits & Vegetables": "🥦",
"Pasta, Rice & Grains": "🍝",
"Ready Meals": "🍲",
"Condiments & Sauces": "🫙",
"Snacks & Sweets": "🍫",
"Frozen Foods": "❄️",
"Canned & Preserved": "🥫",
"Personal Care": "🧴",
Household: "🧹",
Other: "📦",
Uncategorized: "❓",
};
function ecoscoreColor(g) {
return (
{ A: "#1b7340", B: "#56a02a", C: "#d4a017", D: "#c0541a", E: "#9b1c1c" }[
(g || "").toUpperCase()
] || "#999"
);
}
function nutriscoreColor(g) {
return (
{ A: "#038141", B: "#85BB2F", C: "#FECB02", D: "#EE8100", E: "#E63312" }[
(g || "").toUpperCase()
] || "#999"
);
}
function novaColor(g) {
return (
{ 1: "#4CAF50", 2: "#8BC34A", 3: "#FF9800", 4: "#F44336" }[g] || "#999"
);
}
function mediterraneanScoreColor(score) {
if (score == null) return "#999";
if (score >= 75) return "#2d7a3a";
if (score >= 50) return "#85BB2F";
if (score >= 25) return "#EE8100";
return "#E63312";
}
function fmt(n, decimals = 2) {
return n == null ? "—" : Number(n).toFixed(decimals);
}
function offImg(off_id) {
if (!off_id) return null;
return `/api/image/${encodeURIComponent(off_id)}`;
}
const PLACEHOLDER_IMG =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 48 48'%3E%3Crect width='48' height='48' rx='0' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='54%25' dominant-baseline='middle' text-anchor='middle' font-size='22' fill='%23d1d5db'%3E%F0%9F%9B%92%3C/text%3E%3C/svg%3E";
// ── Shared small components ───────────────────────────────────────────────────
function BarcodeBlock({ code, isWeighted }) {
if (!code || isWeighted) return null;
// The EAN13 barcode font requires exactly 13 digits; anything else (e.g.
const canRenderBarcode = /^\d{13}$/.test(code);
return (
{canRenderBarcode ? (
{code}
) : (
{code}
)}
);
}
function ScoreBadge({ grade, colorFn, title }) {
if (
!grade ||
grade === "?" ||
grade.toLowerCase() === "unknown" ||
grade.toLowerCase() === "not-applicable"
)
return null;
return (
{grade}
);
}
function MatchBorder({ matched, total }) {
const pct = total > 0 ? matched / total : 0;
const color = "#2d7a3a";
return (
);
}
const LEVEL_COLOR = { high: "#e53935", moderate: "#ff9800", low: "#4caf50" };
const LEVEL_LABEL = { high: "Alto", moderate: "Med", low: "Basso" };
function LevelPill({ level }) {
if (!level) return null;
return (
{LEVEL_LABEL[level] || level}
);
}
function StatusPill({
value,
yesLabel,
noLabel,
yesColor = "#4caf50",
noColor = "#e53935",
}) {
if (!value || value === "unknown") return null;
const isYes = value === "yes";
return (
{isYes ? yesLabel : noLabel}
);
}
function PanelSection({ title, children }) {
return (
);
}
function ConfirmDialog({
title,
message,
confirmLabel = "Confirm",
danger = false,
onConfirm,
onCancel,
}) {
return (
{title && (
{title}
)}
{message &&
{message}
}
Annulla
{confirmLabel}
);
}
function MatchInfo({ match }) {
if (!match)
return non abbinato ;
const link = match.off_found
? `https://world.openfoodfacts.org/product/${match.barcode}`
: null;
return (
{match.product_name || "—"}
{link && (
Open Food Facts ↗
)}
{match.brands && (
{match.brands}
)}
{match.quantity && (
{match.quantity}
)}
{match.suggested_from && (
da {match.suggested_from}
)}
);
}