Files
EverShelf/assets/js/app.js
T
dadaloop82 c5f22fdf42 feat: BarcodeDetector nativo + camera selector + recipe dedup + remote debug logging
- Scanner: usa BarcodeDetector API nativa (Chrome Android) come primario, Quagga come fallback
- Settings: aggiunta tab Fotocamera per scegliere posteriore/anteriore/specifica
- Scanner feedback: barra verde (scansione attiva), gialla (barcode rilevato)
- Ricette: invio titoli ricette del giorno per evitare duplicati nello stesso giorno
- Debug: sistema di logging remoto (client_debug.log) per diagnostica da dispositivi chioscati
- Fix: permessi .env per scrittura da Apache
2026-03-12 17:32:54 +00:00

4623 lines
192 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Dispensa Manager - Main Application JS
* Complete pantry management with barcode scanning and AI identification
*/
// ===== CONFIGURATION =====
const API_BASE = 'api/index.php';
const LOCATIONS = {
'dispensa': { icon: '🗄️', label: 'Dispensa' },
'frigo': { icon: '🧊', label: 'Frigo' },
'freezer': { icon: '❄️', label: 'Freezer' },
'altro': { icon: '📦', label: 'Altro' },
};
const CATEGORY_ICONS = {
'latticini': '🥛', 'carne': '🥩', 'pesce': '🐟', 'frutta': '🍎',
'verdura': '🥬', 'pasta': '🍝', 'pane': '🍞', 'surgelati': '🧊',
'bevande': '🥤', 'condimenti': '🧂', 'snack': '🍪', 'conserve': '🥫',
'cereali': '🌾', 'igiene': '🧴', 'pulizia': '🧹', 'altro': '📦'
};
// Auto-detect location based on category and product name
const CATEGORY_LOCATION = {
'latticini': 'frigo', 'carne': 'frigo', 'pesce': 'frigo',
'frutta': 'frigo', 'verdura': 'frigo', 'surgelati': 'freezer',
'pasta': 'dispensa', 'pane': 'dispensa', 'bevande': 'dispensa',
'condimenti': 'dispensa', 'snack': 'dispensa', 'conserve': 'dispensa',
'cereali': 'dispensa', 'igiene': 'altro', 'pulizia': 'altro', 'altro': 'dispensa'
};
// Map Open Food Facts categories to local categories
function mapToLocalCategory(ofCategory, productName) {
if (!ofCategory) {
// No category tag — try to guess from product name
return guessCategoryFromName(productName || '');
}
const cat = ofCategory.toLowerCase();
// Direct match with our local keys
for (const key of Object.keys(CATEGORY_ICONS)) {
if (cat === key) return key;
}
// Handle specific Open Food Facts tags FIRST (before generic regex)
// "plant-based-foods-and-beverages" is a catch-all — use product name to decide
if (/plant-based-foods/.test(cat)) {
return guessCategoryFromName(productName || '');
}
// "beverages-and-beverages-preparations" = actual beverages
if (/^en:beverages/.test(cat)) return 'bevande';
// sweeteners = condimenti
if (/sweetener|dolcific/.test(cat)) return 'condimenti';
// Specific tag patterns
if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte/.test(cat)) return 'latticini';
if (/meat|viande|carne|sausage|salum|prosciutt/.test(cat)) return 'carne';
if (/fish|poisson|pesce|seafood|tuna|tonno|salmone/.test(cat)) return 'pesce';
if (/fruit|frutta|juice|succo|apple|banana/.test(cat)) return 'frutta';
if (/vegetable|verdur|legum|salad|insalat|tomato|pomodor/.test(cat)) return 'verdura';
if (/pasta|rice|riso|noodle|spaghetti|penne|grain/.test(cat)) return 'pasta';
if (/bread|pane|forno|biscott|toast|cracker|grissini|fette/.test(cat)) return 'pane';
if (/frozen|surgelé|surgel|gelat/.test(cat)) return 'surgelati';
if (/sauce|condiment|oil|olio|vinegar|aceto|mayo|ketchup|spice|salt|sugar|zuccher/.test(cat)) return 'condimenti';
if (/snack|chip|crisp|chocolate|cioccolat|candy|biscuit|cookie|wafer|merendine|patatine/.test(cat)) return 'snack';
if (/conserve|canned|can|pelati|passata|preserve|jam|marmellat|miele|honey/.test(cat)) return 'conserve';
if (/cereal|muesli|granola|oat|fiocchi/.test(cat)) return 'cereali';
if (/hygiene|soap|shampoo|igien|dentifricio|deodorant/.test(cat)) return 'igiene';
if (/clean|detergent|pulizia|detersiv/.test(cat)) return 'pulizia';
// Beverage check LAST (to avoid false matches on compound tags)
if (/^(?!.*plant-based).*(beverage|drink|boisson|bevand|water|acqua|beer|birra|wine|vino|coffee|caffè|tea\b)/.test(cat)) return 'bevande';
return 'altro';
}
// Guess a local category purely from product name
function guessCategoryFromName(name) {
if (!name) return 'altro';
const n = name.toLowerCase();
// Pasta & Rice
if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio/.test(n)) return 'pasta';
// Pane & Forno
if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli/.test(n)) return 'pane';
// Conserve
if (/passata|pelati|pomodoro|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive/.test(n)) return 'conserve';
// Condimenti
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa/.test(n)) return 'condimenti';
// Bevande
if (/acqua|birra|vino|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|latte\b/.test(n)) return 'bevande';
// Latticini
if (/latte\b|yogurt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b/.test(n)) return 'latticini';
// Carne
if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck/.test(n)) return 'carne';
// Pesce
if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe/.test(n)) return 'pesce';
// Frutta
if (/mela|mele|banana|arancia|pera|fragola|uva|kiwi|limone|frutta/.test(n)) return 'frutta';
// Verdura
if (/insalata|zucchina|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata/.test(n)) return 'verdura';
// Surgelati
if (/surgelat|frozen|findus|4.salti|gelato/.test(n)) return 'surgelati';
// Snack
if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini/.test(n)) return 'snack';
// Cereali
if (/cereali|muesli|fiocchi|granola|polenta/.test(n)) return 'cereali';
// Igiene / Pulizia
if (/sapone|shampoo|dentifricio|deodorante/.test(n)) return 'igiene';
if (/detersivo|pulito|sgrassatore/.test(n)) return 'pulizia';
return 'altro';
}
// Determine safety level for expired products
// Returns { level: 'danger'|'warning'|'ok', icon, label, tip }
function getExpiredSafety(item, daysExpired) {
const cat = mapToLocalCategory(item.category || '', item.name || '');
const loc = (item.location || '').toLowerCase();
const inFreezer = loc === 'freezer';
const inFrigo = loc === 'frigo';
// === FREEZER: il congelamento allunga molto la vita ===
// Carne/pesce in freezer: +3 mesi. Verdura/frutta: +6 mesi. Pane: +2 mesi.
// Latticini in freezer: +1-2 mesi. Tutto il resto: +3-6 mesi.
if (inFreezer) {
const highRiskFreezer = ['carne', 'pesce'];
const medRiskFreezer = ['latticini', 'pane'];
const produceRiskFreezer = ['verdura', 'frutta'];
let bonusDays;
if (highRiskFreezer.includes(cat)) bonusDays = 90; // +3 mesi
else if (produceRiskFreezer.includes(cat)) bonusDays = 180; // +6 mesi
else if (medRiskFreezer.includes(cat)) bonusDays = 60; // +2 mesi
else bonusDays = 120; // +4 mesi default
const effectiveDays = daysExpired - bonusDays;
if (effectiveDays <= 0) {
return { level: 'ok', icon: '✅', label: 'OK', tip: `In freezer: ancora sicuro (~${bonusDays - daysExpired}g di margine)` };
}
if (effectiveDays <= 30) {
return { level: 'warning', icon: '👀', label: 'Controlla', tip: `In freezer da molto, potrebbe aver perso qualità. Consumare presto` };
}
return { level: 'danger', icon: '🗑️', label: 'Buttare', tip: 'In freezer da troppo tempo, rischio di bruciatura da gelo e degrado' };
}
// === FRIGO e DISPENSA ===
const highRisk = ['latticini', 'carne', 'pesce', 'verdura', 'frutta'];
const medRisk = ['pane', 'surgelati'];
if (highRisk.includes(cat)) {
if (inFrigo && daysExpired <= 2) {
return { level: 'warning', icon: '👀', label: 'Controlla', tip: 'Scaduto da poco, controlla odore e aspetto prima di consumare' };
}
return { level: 'danger', icon: '🗑️', label: 'Buttare', tip: 'Prodotto deperibile scaduto: da buttare per sicurezza' };
}
if (medRisk.includes(cat)) {
if (daysExpired <= 7) {
return { level: 'warning', icon: '👀', label: 'Controlla', tip: 'Controlla aspetto e odore prima di consumare' };
}
if (daysExpired <= 30) {
return { level: 'warning', icon: '👀', label: 'Controlla', tip: 'Scaduto da un po\', verificare bene prima dell\'uso' };
}
return { level: 'danger', icon: '🗑️', label: 'Buttare', tip: 'Troppo tempo dalla scadenza, meglio buttare' };
}
// LOW RISK - lunga conservazione (pasta, conserve, condimenti, cereali, snack)
if (daysExpired <= 30) {
return { level: 'ok', icon: '✅', label: 'OK', tip: 'Prodotto a lunga conservazione, ancora sicuro da consumare' };
}
if (daysExpired <= 180) {
return { level: 'warning', icon: '👀', label: 'Controlla', tip: 'Scaduto da oltre un mese, controllare integrità confezione' };
}
return { level: 'danger', icon: '🗑️', label: 'Buttare', tip: 'Scaduto da troppo tempo, meglio non rischiare' };
}
// Nice Italian labels for local categories
const CATEGORY_LABELS = {
'latticini': '🥛 Latticini', 'carne': '🥩 Carne', 'pesce': '🐟 Pesce',
'frutta': '🍎 Frutta', 'verdura': '🥬 Verdura', 'pasta': '🍝 Pasta & Riso',
'pane': '🍞 Pane & Forno', 'surgelati': '🧊 Surgelati', 'bevande': '🥤 Bevande',
'condimenti': '🧂 Condimenti', 'snack': '🍪 Snack & Dolci', 'conserve': '🥫 Conserve',
'cereali': '🌾 Cereali & Legumi', 'igiene': '🧴 Igiene', 'pulizia': '🧹 Pulizia',
'altro': '📦 Altro'
};
// Detect best unit/quantity from Open Food Facts quantity_info string
// Returns the actual package weight/volume as default (e.g. 700g → unit:'g', quantity:700)
function detectUnitAndQuantity(quantityInfo) {
if (!quantityInfo) return { unit: 'pz', quantity: 1, weightInfo: '' };
const q = quantityInfo.toLowerCase().trim();
// Match multi-pack patterns like "6 x 1l", "4 x 125g" → total weight
const multiMatch = q.match(/(\d+)\s*x\s*([\d.,]+)\s*(ml|l|g|kg|cl)/i);
if (multiMatch) {
const count = parseInt(multiMatch[1]);
let perUnitVal = parseFloat(multiMatch[2].replace(',', '.'));
let perUnitUnit = multiMatch[3].toLowerCase();
if (perUnitUnit === 'cl') { perUnitUnit = 'ml'; perUnitVal *= 10; }
const totalVal = count * perUnitVal;
return { unit: perUnitUnit, quantity: totalVal, weightInfo: quantityInfo };
}
// Match single package patterns like "500 g", "1 l", "750 ml", "1.5 kg"
const match = q.match(/([\d.,]+)\s*(kg|g|l|ml|cl)/i);
if (match) {
let unit = match[2].toLowerCase();
let val = parseFloat(match[1].replace(',', '.'));
if (unit === 'cl') { unit = 'ml'; val *= 10; }
return { unit, quantity: val, weightInfo: quantityInfo };
}
return { unit: 'pz', quantity: 1, weightInfo: quantityInfo };
}
// Estimate expiry days based on category/product type
const EXPIRY_DAYS = {
'latticini': 7, 'carne': 4, 'pesce': 3, 'frutta': 7, 'verdura': 7,
'pasta': 730, 'pane': 4, 'surgelati': 180, 'bevande': 365, 'condimenti': 365,
'snack': 180, 'conserve': 730, 'cereali': 365, 'igiene': 1095, 'pulizia': 1095, 'altro': 180
};
// More specific expiry by product name keywords
function estimateExpiryDays(product) {
const name = (product.name || '').toLowerCase();
const cat = (product.category || '').toLowerCase();
// Specific product overrides
if (/latte\s+(fresco|intero|parzial|scremato)/.test(name)) return 7;
if (/latte\s+uht|latte\s+a\s+lunga/.test(name)) return 90;
if (/yogurt/.test(name)) return 21;
if (/mozzarella|burrata|stracciatella/.test(name)) return 5;
if (/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 10;
if (/parmigiano|grana|pecorino|provolone/.test(name)) return 60;
if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) return 7;
if (/prosciutto\s+crudo|salame|bresaola|speck/.test(name)) return 30;
if (/uova/.test(name)) return 28;
if (/pane\s+fresco|pane\s+in\s+cassetta/.test(name)) return 5;
if (/pane\s+confezionato|pan\s+carr|pancarrè/.test(name)) return 14;
if (/insalata|rucola|spinaci\s+freschi/.test(name)) return 5;
if (/pollo|tacchino|maiale|manzo|vitello/.test(name)) return 3;
if (/salmone|tonno\s+fresco|pesce/.test(name) && !/tonno\s+in\s+scatola|tonno\s+rio/.test(name)) return 2;
if (/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/.test(name)) return 1095;
if (/surgelat|frozen|findus|4\s*salti/.test(name)) return 180;
if (/gelato/.test(name)) return 365;
if (/succo|spremuta/.test(name)) return 7;
if (/birra|vino/.test(name)) return 365;
if (/acqua/.test(name)) return 365;
if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) return 180;
if (/nutella|marmellata|miele/.test(name)) return 365;
if (/passata|pelati|pomodor/.test(name)) return 730;
if (/olio|aceto/.test(name)) return 548;
// Fallback to category
for (const [key, days] of Object.entries(EXPIRY_DAYS)) {
if (cat.includes(key)) return days;
}
return 180; // generic default
}
function formatEstimatedExpiry(days) {
if (days <= 7) return `~${days} giorni`;
if (days <= 30) return `~${Math.round(days / 7)} settimane`;
if (days <= 365) return `~${Math.round(days / 30)} mesi`;
return `~${Math.round(days / 365)} anni`;
}
function addDays(days) {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().split('T')[0];
}
// Guess location from product name keywords (fallback if no category)
function guessLocationFromName(name) {
const n = (name || '').toLowerCase();
// Frigo keywords
if (/latte|yogurt|formaggio|mozzarella|burro|panna|uova|prosciutto|salame|wurstel|ricotta|mascarpone|gorgonzola|insalata|rucola|spinaci|pollo|manzo|maiale|salmone|tonno fresco|bresaola/.test(n)) return 'frigo';
// Freezer keywords
if (/surgel|frozen|gelato|ghiaccioli|bastoncini|findus|4 salti|pizza surgel|verdure surgel|minestrone surg/.test(n)) return 'freezer';
// Dispensa keywords
if (/pasta|riso|farina|zucchero|sale|olio|aceto|biscott|cracker|grissini|caffè|tè|the |tea |tonno|pelati|passata|legumi|ceci|fagioli|lenticchie|cereali|muesli|marmell|nutella|miele|cioccolat/.test(n)) return 'dispensa';
return null; // unknown
}
function guessLocation(product) {
// 1. Category-based
if (product.category) {
const cat = product.category.toLowerCase().replace(/^en:/, '').split(',')[0].trim();
// Check our map
for (const [key, loc] of Object.entries(CATEGORY_LOCATION)) {
if (cat.includes(key)) return loc;
}
// Open Food Facts categories
if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin/i.test(cat)) return 'frigo';
if (/meat|viande|carne|fish|poisson|pesce/i.test(cat)) return 'frigo';
if (/frozen|surgelé|surgel/i.test(cat)) return 'freezer';
if (/fruit|vegetable|verdur|frutta/i.test(cat)) return 'frigo';
if (/beverage|drink|boisson|bevand/i.test(cat)) return 'dispensa';
if (/pasta|cereal|grain|bread|biscuit|snack|sauce|condiment|conserv|can/i.test(cat)) return 'dispensa';
}
// 2. Name-based fallback
const nameLoc = guessLocationFromName(product.name);
if (nameLoc) return nameLoc;
// 3. Default
return 'dispensa';
}
// ===== STATE =====
let currentProduct = null;
let currentInventory = [];
let currentLocation = '';
let scannerStream = null;
let quaggaRunning = false;
let aiStream = null;
// ===== CAMERA HELPER =====
function getCameraConstraints(extraVideo = {}) {
const s = getSettings();
const mode = s.camera_facing || 'environment';
// Front cameras on older devices often have lower resolution — don't over-request
const isFront = (mode === 'user');
const videoConstraints = {
width: { ideal: isFront ? 640 : 1280 },
height: { ideal: isFront ? 480 : 720 },
...extraVideo
};
if (mode === 'environment' || mode === 'user') {
videoConstraints.facingMode = mode;
} else {
// Specific deviceId selected
videoConstraints.deviceId = { exact: mode };
}
return { video: videoConstraints };
}
function isFrontCamera() {
const s = getSettings();
return (s.camera_facing || 'environment') === 'user';
}
async function enumerateCameras() {
try {
// Need a temporary stream to get device labels
const tempStream = await navigator.mediaDevices.getUserMedia({ video: true });
const devices = await navigator.mediaDevices.enumerateDevices();
tempStream.getTracks().forEach(t => t.stop());
return devices.filter(d => d.kind === 'videoinput');
} catch(e) {
return [];
}
}
// ===== SETTINGS / CONFIG =====
function getSettings() {
try {
const s = JSON.parse(localStorage.getItem('dispensa_settings') || '{}');
// Build recipe_prefs array from individual booleans
s.recipe_prefs = [];
if (s.pref_veloce) s.recipe_prefs.push('veloce');
if (s.pref_pocafame) s.recipe_prefs.push('pocafame');
if (s.pref_scadenze) s.recipe_prefs.push('scadenze');
if (s.pref_healthy) s.recipe_prefs.push('salutare');
if (s.pref_comfort) s.recipe_prefs.push('comfort');
if (s.pref_zerowaste) s.recipe_prefs.push('zerowaste');
s.dietary_restrictions = s.dietary || '';
return s;
} catch(e) { return {}; }
}
function saveSettingsToStorage(settings) {
localStorage.setItem('dispensa_settings', JSON.stringify(settings));
}
async function loadSettingsUI() {
const s = getSettings();
document.getElementById('setting-gemini-key').value = s.gemini_key || '';
document.getElementById('setting-bring-email').value = s.bring_email || '';
document.getElementById('setting-bring-password').value = s.bring_password || '';
document.getElementById('setting-default-persons').value = s.default_persons || 1;
document.getElementById('setting-pref-veloce').checked = !!s.pref_veloce;
document.getElementById('setting-pref-pocafame').checked = !!s.pref_pocafame;
document.getElementById('setting-pref-scadenze').checked = !!s.pref_scadenze;
document.getElementById('setting-pref-healthy').checked = !!s.pref_healthy;
document.getElementById('setting-pref-comfort').checked = !!s.pref_comfort;
document.getElementById('setting-pref-zerowaste').checked = !!s.pref_zerowaste;
document.getElementById('setting-dietary').value = s.dietary || '';
// Camera
const cameraSelect = document.getElementById('setting-camera-facing');
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
loadCameraDevices();
renderAppliances(s.appliances || []);
loadSpesaSettings();
// Load server-side settings if not already set locally
try {
const serverSettings = await api('get_settings');
if (!s.gemini_key && serverSettings.gemini_key) {
document.getElementById('setting-gemini-key').value = serverSettings.gemini_key;
}
if (!s.bring_email && serverSettings.bring_email) {
document.getElementById('setting-bring-email').value = serverSettings.bring_email;
}
} catch(e) { /* ignore */ }
}
function renderAppliances(appliances) {
const container = document.getElementById('appliances-list');
if (!appliances || appliances.length === 0) {
container.innerHTML = '<p style="color:var(--text-muted);font-size:0.85rem;padding:8px 0">Nessun elettrodomestico aggiunto</p>';
return;
}
container.innerHTML = appliances.map((a, i) => `
<div class="appliance-item">
<span>🔌 ${escapeHtml(a)}</span>
<button class="appliance-remove" onclick="removeAppliance(${i})" title="Rimuovi">✕</button>
</div>
`).join('');
}
async function loadCameraDevices() {
const select = document.getElementById('setting-camera-facing');
if (!select) return;
const s = getSettings();
const current = s.camera_facing || 'environment';
// Remove old device-specific options (keep first 2: environment, user)
while (select.options.length > 2) select.remove(2);
const cameras = await enumerateCameras();
cameras.forEach(cam => {
const opt = document.createElement('option');
opt.value = cam.deviceId;
opt.textContent = cam.label || `Camera ${cam.deviceId.slice(0, 8)}…`;
select.appendChild(opt);
});
select.value = current;
}
function addAppliance() {
const input = document.getElementById('new-appliance-input');
const name = (input.value || '').trim();
if (!name) return;
const s = getSettings();
if (!s.appliances) s.appliances = [];
if (s.appliances.some(a => a.toLowerCase() === name.toLowerCase())) {
showToast('Elettrodomestico già presente', 'error');
return;
}
s.appliances.push(name);
saveSettingsToStorage(s);
renderAppliances(s.appliances);
input.value = '';
showToast('Elettrodomestico aggiunto', 'success');
}
function addApplianceQuick(name) {
const s = getSettings();
if (!s.appliances) s.appliances = [];
if (s.appliances.some(a => a.toLowerCase() === name.toLowerCase())) {
showToast('Già presente', 'error');
return;
}
s.appliances.push(name);
saveSettingsToStorage(s);
renderAppliances(s.appliances);
showToast(`${name} aggiunto`, 'success');
}
function removeAppliance(idx) {
const s = getSettings();
if (!s.appliances) return;
s.appliances.splice(idx, 1);
saveSettingsToStorage(s);
renderAppliances(s.appliances);
}
async function saveSettings() {
const s = getSettings();
s.gemini_key = document.getElementById('setting-gemini-key').value.trim();
s.bring_email = document.getElementById('setting-bring-email').value.trim();
s.bring_password = document.getElementById('setting-bring-password').value.trim();
s.default_persons = parseInt(document.getElementById('setting-default-persons').value) || 1;
s.pref_veloce = document.getElementById('setting-pref-veloce').checked;
s.pref_pocafame = document.getElementById('setting-pref-pocafame').checked;
s.pref_scadenze = document.getElementById('setting-pref-scadenze').checked;
s.pref_healthy = document.getElementById('setting-pref-healthy').checked;
s.pref_comfort = document.getElementById('setting-pref-comfort').checked;
s.pref_zerowaste = document.getElementById('setting-pref-zerowaste').checked;
s.dietary = document.getElementById('setting-dietary').value.trim();
// Camera
s.camera_facing = document.getElementById('setting-camera-facing').value;
// Save spesa AI prompt if the field exists
const spesaPromptEl = document.getElementById('setting-spesa-ai-prompt');
if (spesaPromptEl) s.spesa_ai_prompt = spesaPromptEl.value.trim();
saveSettingsToStorage(s);
// Also save to server .env
try {
const result = await api('save_settings', {}, 'POST', {
gemini_key: s.gemini_key,
bring_email: s.bring_email,
bring_password: s.bring_password
});
const statusEl = document.getElementById('settings-status');
if (result.success) {
statusEl.className = 'settings-status success';
statusEl.textContent = '✅ Configurazione salvata!';
} else {
statusEl.className = 'settings-status error';
statusEl.textContent = '⚠️ Salvato localmente, errore server: ' + (result.error || '');
}
statusEl.style.display = 'block';
setTimeout(() => statusEl.style.display = 'none', 4000);
} catch(e) {
const statusEl = document.getElementById('settings-status');
statusEl.className = 'settings-status success';
statusEl.textContent = '✅ Configurazione salvata localmente';
statusEl.style.display = 'block';
setTimeout(() => statusEl.style.display = 'none', 4000);
}
}
function switchSettingsTab(btn, tabId) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(tabId).classList.add('active');
}
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
input.type = input.type === 'password' ? 'text' : 'password';
}
// ===== API HELPER =====
async function api(action, params = {}, method = 'GET', body = null) {
let url = `${API_BASE}?action=${action}`;
if (method === 'GET') {
Object.entries(params).forEach(([k, v]) => {
url += `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
});
}
const opts = { method };
if (body) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
return res.json();
}
// ===== PAGE NAVIGATION =====
// Track current page for auto-refresh
let _currentPageId = 'dashboard';
let _currentPageParam = null;
// Refresh current page data without full navigation
function refreshCurrentPage() {
switch(_currentPageId) {
case 'dashboard': loadDashboard(); break;
case 'inventory': loadInventory(); break;
case 'shopping': loadShoppingList(); break;
case 'products': loadAllProducts(); break;
}
}
function showPage(pageId, param = null) {
_currentPageId = pageId;
_currentPageParam = param;
// Hide all pages
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
// Show target page
const page = document.getElementById(`page-${pageId}`);
if (page) page.classList.add('active');
// Update nav
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`.nav-btn[data-page="${pageId}"]`);
if (navBtn) navBtn.classList.add('active');
// Page-specific init
switch(pageId) {
case 'dashboard': loadDashboard(); break;
case 'inventory':
if (param !== null) {
currentLocation = param;
filterLocation(param);
}
loadInventory();
break;
case 'scan': initScanner(); clearQuickNameResults(); break;
case 'products': loadAllProducts(); break;
case 'shopping': loadShoppingList(); break;
case 'recipe': loadRecipeArchive(); break;
case 'log': loadLog(); break;
case 'ai': initAICamera(); break;
case 'settings': loadSettingsUI(); break;
case 'chat': initChat(); break;
}
// Stop scanner when leaving scan page
if (pageId !== 'scan' && pageId !== 'ai') {
stopScanner();
}
// Scroll to top
window.scrollTo(0, 0);
}
// ===== DASHBOARD =====
async function loadDashboard() {
try {
const [summaryData, statsData] = await Promise.all([
api('inventory_summary'),
api('stats')
]);
// Update stat cards
const summary = summaryData.summary || [];
let total = 0;
['dispensa', 'frigo', 'freezer'].forEach(loc => {
const s = summary.find(x => x.location === loc);
const count = s ? s.product_count : 0;
document.getElementById(`stat-${loc}`).textContent = count;
total += count;
});
// Add non-standard locations
summary.forEach(s => {
if (!['dispensa', 'frigo', 'freezer'].includes(s.location)) {
total += s.product_count;
}
});
// Load shopping list count from Bring!
loadShoppingCount();
// Expiring items
const expiringSection = document.getElementById('alert-expiring');
const expiringList = document.getElementById('expiring-list');
if (statsData.expiring_soon && statsData.expiring_soon.length > 0) {
expiringSection.style.display = 'block';
expiringList.innerHTML = statsData.expiring_soon.map(item => {
const days = daysUntilExpiry(item.expiry_date);
let badgeText, badgeClass;
if (days === 0) { badgeText = 'OGGI'; badgeClass = 'today'; }
else if (days === 1) { badgeText = 'Domani'; badgeClass = 'expiring'; }
else if (days <= 7) { badgeText = `${days} giorni`; badgeClass = 'expiring'; }
else if (days <= 30) { badgeText = `${days}g`; badgeClass = 'expiring-soon'; }
else { const m = Math.round(days/30); badgeText = m <= 1 ? `${days}g` : `~${m} mesi`; badgeClass = 'expiring-later'; }
const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
return `
<div class="alert-item alert-item-clickable" onclick="showAlertItemDetail(${item.id}, ${item.product_id})">
<div class="alert-item-info">
<span class="alert-item-name">${escapeHtml(item.name)}</span>
${item.brand ? `<span class="alert-item-brand">${escapeHtml(item.brand)}</span>` : ''}
</div>
<div class="alert-item-badges">
<span class="alert-item-qty">📦 ${qtyDisplay}</span>
<span class="alert-item-badge ${badgeClass}">${badgeText}</span>
</div>
</div>`;
}).join('');
} else {
expiringSection.style.display = 'none';
}
// Expired items
const expiredSection = document.getElementById('alert-expired');
const expiredList = document.getElementById('expired-list');
if (statsData.expired && statsData.expired.length > 0) {
expiredSection.style.display = 'block';
expiredList.innerHTML = statsData.expired.map(item => {
const days = Math.abs(daysUntilExpiry(item.expiry_date));
let daysText;
if (days === 0) daysText = 'Oggi';
else if (days === 1) daysText = 'Da ieri';
else daysText = `Da ${days}g`;
const safety = getExpiredSafety(item, days);
const locIcon = item.location === 'freezer' ? '❄️' : item.location === 'frigo' ? '🧊' : '';
const qtyDisplayExp = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
return `
<div class="alert-item expired-item alert-item-clickable" onclick="showAlertItemDetail(${item.id}, ${item.product_id})">
<div class="alert-item-info">
<span class="alert-item-name">${locIcon ? locIcon + ' ' : ''}${escapeHtml(item.name)}</span>
${item.brand ? `<span class="alert-item-brand">${escapeHtml(item.brand)}</span>` : ''}
<span class="alert-item-qty">📦 ${qtyDisplayExp}</span>
</div>
<div class="alert-item-badges">
<span class="alert-item-badge expired">${daysText}</span>
<span class="safety-badge safety-${safety.level}" title="${safety.tip}">${safety.icon} ${safety.label}</span>
</div>
</div>`;
}).join('');
} else {
expiredSection.style.display = 'none';
}
// Review suspicious quantities
loadReviewItems();
} catch (err) {
console.error('Dashboard load error:', err);
}
}
// === SUSPICIOUS QUANTITY REVIEW ===
const QTY_THRESHOLDS = {
'pz': { min: 0.3, max: 50 },
'conf': { min: 0.3, max: 50 },
'g': { min: 3, max: 10000 },
'kg': { min: 0.005, max: 50 },
'ml': { min: 3, max: 10000 },
'l': { min: 0.005, max: 50 },
};
function isSuspiciousQty(qty, unit) {
const n = parseFloat(qty);
if (isNaN(n) || n <= 0) return false;
const t = QTY_THRESHOLDS[unit] || QTY_THRESHOLDS['pz'];
return n < t.min || n > t.max;
}
function getReviewConfirmed() {
try { return JSON.parse(localStorage.getItem('review_confirmed') || '{}'); } catch(e) { return {}; }
}
function setReviewConfirmed(inventoryId) {
const c = getReviewConfirmed();
c[inventoryId] = Date.now();
localStorage.setItem('review_confirmed', JSON.stringify(c));
}
async function loadReviewItems() {
const section = document.getElementById('alert-review');
const list = document.getElementById('review-list');
try {
const data = await api('inventory_list');
const items = data.inventory || [];
const confirmed = getReviewConfirmed();
const suspicious = items.filter(item => {
if (confirmed[item.id]) return false;
return isSuspiciousQty(item.quantity, item.unit);
});
if (suspicious.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
list.innerHTML = suspicious.map(item => {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const t = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
const isTooSmall = parseFloat(item.quantity) < t.min;
const warning = isTooSmall ? '⬇️ Troppo poco' : '⬆️ Troppo';
return `
<div class="review-item" id="review-item-${item.id}">
<div class="review-item-info">
<span class="review-item-icon">${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="">` : catIcon}</span>
<div class="review-item-text">
<div class="review-item-name">${escapeHtml(item.name)}</div>
<div class="review-item-meta">${locInfo.icon} ${locInfo.label} · <span class="review-warn">${warning}</span></div>
</div>
</div>
<div class="review-item-qty">
<span class="review-qty-value">${qtyDisplay}</span>
</div>
<div class="review-item-actions">
<button class="btn-review btn-review-ok" onclick="confirmReviewItem(${item.id})" title="È corretto">✓</button>
<button class="btn-review btn-review-edit" onclick="editReviewItem(${item.id}, ${item.product_id})" title="Modifica">✏️</button>
</div>
</div>`;
}).join('');
} catch(e) {
section.style.display = 'none';
}
}
function confirmReviewItem(inventoryId) {
setReviewConfirmed(inventoryId);
const el = document.getElementById(`review-item-${inventoryId}`);
if (el) {
el.style.transition = 'opacity 0.3s, transform 0.3s';
el.style.opacity = '0';
el.style.transform = 'translateX(60px)';
setTimeout(() => {
el.remove();
// Hide section if empty
const list = document.getElementById('review-list');
if (!list.children.length) {
document.getElementById('alert-review').style.display = 'none';
}
}, 300);
}
showToast('✓ Quantità confermata', 'success');
}
function editReviewItem(inventoryId, productId) {
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId);
});
}
// Group items by local category and render with category headers
function renderGroupedByCategory(items, compact = false) {
const catGroups = {};
items.forEach(item => {
const localCat = mapToLocalCategory(item.category, item.name);
if (!catGroups[localCat]) catGroups[localCat] = [];
catGroups[localCat].push(item);
});
// Sort categories: use CATEGORY_ICONS key order
const catOrder = Object.keys(CATEGORY_ICONS);
const sortedCats = Object.keys(catGroups).sort((a, b) => {
const ia = catOrder.indexOf(a);
const ib = catOrder.indexOf(b);
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
});
let html = '';
for (const cat of sortedCats) {
const catItems = catGroups[cat];
const label = CATEGORY_LABELS[cat] || '📦 Altro';
html += `<div class="cat-group-header">${label} <span class="cat-group-count">${catItems.length}</span></div>`;
html += catItems.map(item => compact ? renderDashItem(item) : renderInventoryItem(item)).join('');
}
return html;
}
function renderDashItem(item) {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
const days = daysUntilExpiry(item.expiry_date);
const isExpired = days < 0;
const isExpiring = !isExpired && days <= 7;
const parts = formatQuantityParts(item.quantity, item.unit, item.default_quantity, item.package_unit);
let expiryLabel = '';
if (item.expiry_date) {
if (days < 0) expiryLabel = `⚠️ Scaduto da ${Math.abs(days)}g`;
else if (days === 0) expiryLabel = '⚠️ Scade oggi!';
else if (days === 1) expiryLabel = '⏰ Scade domani';
else if (days <= 7) expiryLabel = `⏰ ${days} giorni`;
else expiryLabel = formatDate(item.expiry_date);
}
return `
<div class="inventory-item compact-item" onclick="dashItemTap(${item.id}, ${item.product_id})">
<div class="inv-image">
${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="" onerror="this.parentElement.innerHTML='${catIcon}'">` : catIcon}
</div>
<div class="inv-info">
<div class="inv-name">${escapeHtml(item.name)}</div>
${item.brand ? `<div class="inv-brand">${escapeHtml(item.brand)}</div>` : ''}
</div>
<div class="inv-qty-right">
<span class="inv-qty-value">${parts.mainQty} <small>${parts.unitLabel}</small></span>
${parts.packageDetail ? `<span class="inv-qty-pkg-detail">${parts.packageDetail}</span>` : ''}
${expiryLabel ? `<span class="inv-expiry-small ${isExpired ? 'expired' : isExpiring ? 'expiring' : ''}">${expiryLabel}</span>` : ''}
</div>
</div>`;
}
function dashItemTap(inventoryId, productId) {
// Load full inventory so modal works
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId);
});
}
function showAlertItemDetail(inventoryId, productId) {
// Load full inventory so modal works (same pattern as dashItemTap)
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId);
});
}
function formatQuantity(qty, unit, defaultQty, packageUnit) {
if (!qty && qty !== 0) return '';
const n = parseFloat(qty);
const unitLabels = { 'pz': 'pz', 'kg': 'kg', 'g': 'g', 'l': 'L', 'ml': 'ml', 'conf': 'conf' };
const label = unitLabels[unit] || unit || 'pz';
let result;
if (n === Math.floor(n)) result = `${Math.floor(n)} ${label}`;
else if (unit === 'pz' || unit === 'conf') result = `${Math.round(n)} ${label}`;
else result = `${n.toFixed(1)} ${label}`;
// Add package info for conf
if (unit === 'conf' && packageUnit && defaultQty > 0) {
const pkgLabel = unitLabels[packageUnit] || packageUnit;
result += ` <span class="conf-size-info">(da ${defaultQty}${pkgLabel})</span>`;
}
return result;
}
// Structured quantity display for inventory cards.
// Returns { mainQty: '10', unitLabel: 'conf', packageDetail: 'da 36g', fraction: '¼' }
function formatQuantityParts(qty, unit, defaultQty, packageUnit) {
const n = parseFloat(qty) || 0;
const unitLabels = { 'pz': 'pz', 'kg': 'kg', 'g': 'g', 'l': 'L', 'ml': 'ml', 'conf': 'conf' };
const label = unitLabels[unit] || unit || 'pz';
let mainQty;
if (n === Math.floor(n)) mainQty = `${Math.floor(n)}`;
else if (unit === 'pz' || unit === 'conf') mainQty = `${Math.round(n)}`;
else mainQty = `${n.toFixed(1)}`;
let packageDetail = '';
if (unit === 'conf' && packageUnit && defaultQty > 0) {
const pkgLabel = unitLabels[packageUnit] || packageUnit;
packageDetail = `da ${defaultQty}${pkgLabel}`;
}
let fraction = '';
// For conf, defaultQty is the package SIZE (e.g. 300g), not a count; fraction doesn't apply
if (unit !== 'conf' && defaultQty && defaultQty > 1) {
const d = parseFloat(defaultQty);
const ratio = n / d;
const remainder = ratio - Math.floor(ratio);
if (remainder >= 0.1 && remainder <= 0.9) {
if (remainder < 0.38) fraction = '¼';
else if (remainder < 0.62) fraction = '½';
else fraction = '¾';
}
}
return { mainQty, unitLabel: label, packageDetail, fraction };
}
// Show package fraction: only ¼, ½, ¾ when there's a partial package.
// Returns '' if quantity maps to whole packages or fraction is not meaningful.
function formatPackageFraction(qty, defaultQty) {
if (!defaultQty || defaultQty <= 0) return '';
const n = parseFloat(qty);
const d = parseFloat(defaultQty);
if (isNaN(n) || isNaN(d) || d <= 0 || d === 1) return '';
const ratio = n / d;
const remainder = ratio - Math.floor(ratio);
// Only show if there IS a fractional part
if (remainder < 0.1 || remainder > 0.9) return '';
let frac = '';
if (remainder < 0.38) frac = '¼';
else if (remainder < 0.62) frac = '½';
else frac = '¾';
return `<span class="pkg-fraction">${frac}</span>`;
}
// ===== INVENTORY =====
async function loadInventory() {
try {
const data = await api('inventory_list', currentLocation ? { location: currentLocation } : {});
currentInventory = data.inventory || [];
renderInventory(currentInventory);
} catch (err) {
console.error('Inventory load error:', err);
}
}
function renderInventoryItem(item) {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const days = daysUntilExpiry(item.expiry_date);
const isExpired = days < 0;
const isExpiring = !isExpired && days <= 7;
const parts = formatQuantityParts(item.quantity, item.unit, item.default_quantity, item.package_unit);
let expiryBadge = '';
if (item.expiry_date) {
let expiryText;
if (isExpired) expiryText = `⚠️ Scaduto da ${Math.abs(days)}g`;
else if (days === 0) expiryText = '⚠️ Scade oggi!';
else if (days === 1) expiryText = '⏰ Domani';
else if (days <= 7) expiryText = `⏰ ${days} giorni`;
else expiryText = formatDate(item.expiry_date);
expiryBadge = `<span class="inv-badge ${isExpired ? 'badge-expired' : isExpiring ? 'badge-expiry' : ''}">${expiryText}</span>`;
}
return `
<div class="inventory-item" onclick="showItemDetail(${item.id}, ${item.product_id})">
<div class="inv-image">
${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="" onerror="this.parentElement.innerHTML='${catIcon}'">` : catIcon}
</div>
<div class="inv-info">
<div class="inv-name">${escapeHtml(item.name)}</div>
${item.brand ? `<div class="inv-brand">${escapeHtml(item.brand)}</div>` : ''}
<div class="inv-meta">
<span class="inv-badge badge-location">${locInfo.icon} ${locInfo.label}</span>
${expiryBadge}
</div>
</div>
<div class="inv-qty-col">
<span class="inv-qty-number">${parts.mainQty}</span>
<span class="inv-qty-unit">${parts.unitLabel}${parts.packageDetail ? ` <span class="inv-qty-pkg">${parts.packageDetail}</span>` : ''}</span>
${parts.fraction ? `<span class="inv-qty-frac">${parts.fraction}</span>` : ''}
</div>
</div>`;
}
function renderInventory(items) {
const container = document.getElementById('inventory-list');
if (items.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📭</div><p>Nessun prodotto qui.<br>Scansiona un prodotto per aggiungerlo!</p></div>';
return;
}
container.innerHTML = renderGroupedByCategory(items, false);
}
function filterLocation(loc) {
currentLocation = loc;
document.querySelectorAll('.location-tabs .tab').forEach(t => {
t.classList.toggle('active', t.dataset.loc === loc);
});
loadInventory();
}
function filterInventory() {
const q = document.getElementById('inventory-search').value.toLowerCase();
if (!q) {
renderInventory(currentInventory);
return;
}
const filtered = currentInventory.filter(i =>
i.name.toLowerCase().includes(q) ||
(i.brand && i.brand.toLowerCase().includes(q)) ||
(i.barcode && i.barcode.includes(q))
);
renderInventory(filtered);
}
// ===== ITEM DETAIL MODAL =====
function showItemDetail(inventoryId, productId) {
const item = currentInventory.find(i => i.id === inventoryId);
if (!item) return;
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${escapeHtml(item.name)}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="product-preview-small" style="margin-bottom:12px">
${item.image_url ?
`<img src="${escapeHtml(item.image_url)}" alt="" style="width:60px;height:60px;border-radius:10px;object-fit:cover">` :
`<span style="font-size:2.5rem">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(item.name)}</h3>
<p>${item.brand ? escapeHtml(item.brand) : ''}</p>
</div>
</div>
<div class="modal-detail">
<div class="modal-detail-row">
<span class="modal-detail-label">📍 Posizione</span>
<span class="modal-detail-value">${locInfo.icon} ${locInfo.label}</span>
</div>
<div class="modal-detail-row">
<span class="modal-detail-label">📦 Quantità</span>
<span class="modal-detail-value">${formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit)}</span>
</div>
${item.expiry_date ? `
<div class="modal-detail-row">
<span class="modal-detail-label">📅 Scadenza</span>
<span class="modal-detail-value">${formatDate(item.expiry_date)}</span>
</div>` : ''}
${item.barcode ? `
<div class="modal-detail-row">
<span class="modal-detail-label">🔖 Barcode</span>
<span class="modal-detail-value">${item.barcode}</span>
</div>` : ''}
<div class="modal-detail-row">
<span class="modal-detail-label">📅 Aggiunto</span>
<span class="modal-detail-value">${formatDateTime(item.added_at)}</span>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-danger flex-1" onclick="quickUse(${item.product_id}, '${item.location}')">📤 Usa</button>
<button class="btn btn-primary flex-1" onclick="editInventoryItem(${inventoryId})">✏️ Modifica</button>
<button class="btn btn-secondary" onclick="deleteInventoryItem(${inventoryId})" style="padding:12px">🗑️</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
function closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
}
async function quickUse(productId, location) {
closeModal();
showLoading(true);
try {
currentProduct = { id: productId };
// Get product info
const data = await api('product_get', { id: productId });
if (data.product) {
currentProduct = data.product;
// Extract weight_info from notes if available
if (!currentProduct.weight_info && currentProduct.notes) {
const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/);
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
}
}
document.getElementById('use-location').value = location;
// Mark active location button
document.querySelectorAll('#page-use .loc-btn').forEach(b => b.classList.remove('active'));
const locBtns = document.querySelectorAll('#page-use .loc-btn');
locBtns.forEach(b => {
if (b.textContent.toLowerCase().includes(location)) b.classList.add('active');
});
renderUsePreview();
loadUseInventoryInfo();
showLoading(false);
showPage('use');
} catch (err) {
showLoading(false);
console.error('quickUse error:', err);
showToast('Errore nel caricamento del prodotto', 'error');
}
}
async function deleteInventoryItem(id) {
if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) {
await api('inventory_delete', {}, 'POST', { id });
closeModal();
showToast('Prodotto rimosso', 'success');
refreshCurrentPage();
}
}
function editInventoryItem(id) {
const item = currentInventory.find(i => i.id === id);
if (!item) {
closeModal();
showToast('Prodotto non trovato', 'error');
return;
}
const isConf = (item.unit || 'pz') === 'conf';
const confSizeVal = (isConf && item.default_quantity > 0) ? item.default_quantity : '';
const confUnitVal = (isConf && item.package_unit) ? item.package_unit : 'g';
// Rebuild modal content for editing (don't close and reopen - just replace content)
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>Modifica ${escapeHtml(item.name)}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<form class="form" onsubmit="submitEditInventory(event, ${id}, ${item.product_id})">
<div class="form-group">
<label>📦 Quantità</label>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', -1)"></button>
<input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button>
</div>
</div>
<div class="form-group">
<label>📏 Unità di misura</label>
<select id="edit-unit" class="form-input" onchange="onEditUnitChange()">
${['pz','g','kg','ml','l','conf'].map(u => `<option value="${u}" ${(item.unit||'pz') === u ? 'selected' : ''}>${u === 'pz' ? 'pz (pezzi)' : u === 'g' ? 'g (grammi)' : u === 'kg' ? 'kg (chilogrammi)' : u === 'ml' ? 'ml (millilitri)' : u === 'l' ? 'L (litri)' : u === 'conf' ? 'conf (confezioni)' : u}</option>`).join('')}
</select>
</div>
<div class="form-group" id="edit-conf-size-group" style="display:${isConf ? 'block' : 'none'}">
<label>📦 Ogni confezione contiene:</label>
<div class="conf-size-inputs">
<input type="number" id="edit-conf-size" class="form-input conf-size-input" min="1" step="any" value="${confSizeVal}" placeholder="es. 300">
<select id="edit-conf-unit" class="form-input conf-size-unit">
${['g','kg','ml','l'].map(u => `<option value="${u}" ${confUnitVal === u ? 'selected' : ''}>${u === 'l' ? 'L' : u}</option>`).join('')}
</select>
</div>
</div>
<div class="form-group">
<label>📍 Posizione</label>
<div class="location-selector">
${Object.entries(LOCATIONS).map(([k, v]) => `
<button type="button" class="loc-btn ${item.location === k ? 'active' : ''}"
onclick="this.parentElement.querySelectorAll('.loc-btn').forEach(b=>b.classList.remove('active'));this.classList.add('active');document.getElementById('edit-loc').value='${k}'">${v.icon} ${v.label}</button>
`).join('')}
</div>
<input type="hidden" id="edit-loc" value="${item.location}">
</div>
<div class="form-group">
<label>📅 Scadenza</label>
<input type="date" id="edit-expiry" value="${item.expiry_date || ''}" class="form-input">
</div>
<button type="submit" class="btn btn-large btn-primary full-width">💾 Salva</button>
</form>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
function onEditUnitChange() {
const unit = document.getElementById('edit-unit').value;
const confGroup = document.getElementById('edit-conf-size-group');
if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none';
}
async function submitEditInventory(e, id, productId) {
e.preventDefault();
const qty = parseFloat(document.getElementById('edit-qty').value);
const loc = document.getElementById('edit-loc').value;
const expiry = document.getElementById('edit-expiry').value || null;
const unit = document.getElementById('edit-unit').value;
const payload = { id, quantity: qty, location: loc, expiry_date: expiry, unit, product_id: productId };
// Add package info if conf
if (unit === 'conf') {
payload.package_unit = document.getElementById('edit-conf-unit')?.value || '';
payload.package_size = parseFloat(document.getElementById('edit-conf-size')?.value) || 0;
} else {
// Clear package info if not conf
payload.package_unit = '';
payload.package_size = 0;
}
await api('inventory_update', {}, 'POST', payload);
closeModal();
showToast('Aggiornato!', 'success');
refreshCurrentPage();
}
// ===== SCAN DEBUG LOG =====
let _scanDebugVisible = false;
let _scanLogBuffer = [];
let _scanLogTimer = null;
function scanLog(msg) {
const el = document.getElementById('scan-debug-log');
if (el) {
const ts = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:1});
el.textContent += `[${ts}] ${msg}\n`;
el.scrollTop = el.scrollHeight;
}
console.log('[ScanDebug]', msg);
// Buffer for remote send
_scanLogBuffer.push(msg);
if (!_scanLogTimer) {
_scanLogTimer = setTimeout(flushScanLog, 2000);
}
}
function flushScanLog() {
_scanLogTimer = null;
if (_scanLogBuffer.length === 0) return;
const msgs = _scanLogBuffer.splice(0);
fetch(`${API_BASE}?action=client_log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: msgs })
}).catch(() => {});
}
function toggleScanDebug() {
const el = document.getElementById('scan-debug-log');
if (!el) return;
_scanDebugVisible = !_scanDebugVisible;
el.style.display = _scanDebugVisible ? 'block' : 'none';
}
// ===== BARCODE SCANNER =====
let _useBarcodeDetector = ('BarcodeDetector' in window);
async function initScanner() {
const video = document.getElementById('scanner-video');
const viewport = document.getElementById('scanner-viewport');
const logEl = document.getElementById('scan-debug-log');
if (logEl) logEl.textContent = '';
const constraints = getCameraConstraints();
scanLog(`Camera mode: ${getSettings().camera_facing || 'environment'}`);
scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (Quagga fallback)'}`);
scanLog(`Constraints: ${JSON.stringify(constraints.video)}`);
try {
stopScanner();
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const track = stream.getVideoTracks()[0];
const caps = track.getSettings ? track.getSettings() : {};
scanLog(`Stream OK — track: ${track.label}`);
scanLog(`Resolution: ${caps.width||'?'}x${caps.height||'?'}, facing: ${caps.facingMode||'N/A'}`);
scannerStream = stream;
video.srcObject = stream;
await video.play();
scanLog(`Video playing — videoWidth: ${video.videoWidth}, videoHeight: ${video.videoHeight}`);
if (_useBarcodeDetector) {
startNativeScanner(video);
} else {
startQuaggaScanner(video);
}
} catch (err) {
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
console.error('Camera error:', err);
document.getElementById('scan-result').style.display = 'block';
document.getElementById('scan-result').innerHTML = `
<p style="color: var(--danger)">⚠️ Impossibile accedere alla fotocamera.</p>
<p style="font-size:0.85rem; color: var(--text-light); margin-top:8px">
Assicurati di usare HTTPS e di aver concesso i permessi della fotocamera.<br>
Puoi inserire il barcode manualmente o usare l'identificazione AI.
</p>
`;
}
}
// ===== NATIVE BarcodeDetector SCANNER =====
async function startNativeScanner(videoEl) {
if (quaggaRunning) return;
const scannerLine = document.querySelector('.scanner-line');
const detector = new BarcodeDetector({
formats: ['ean_13', 'ean_8', 'code_128', 'code_39', 'upc_a', 'upc_e']
});
let scanning = true;
quaggaRunning = true;
let frameCount = 0;
let partialCount = 0;
let lastDetected = '';
let detectCount = 0;
let detectionHistory = {};
scanLog('Native BarcodeDetector started');
function updateFeedback(state) {
if (!scannerLine) return;
scannerLine.classList.remove('scanning', 'detecting');
if (state) scannerLine.classList.add(state);
}
async function scanFrame() {
if (!scanning || !scannerStream) return;
frameCount++;
if (frameCount === 1) updateFeedback('scanning');
try {
const barcodes = await detector.detect(videoEl);
if (barcodes.length > 0) {
const code = barcodes[0].rawValue;
const format = barcodes[0].format;
partialCount++;
scanLog(`Native detect #${partialCount} [f${frameCount}]: ${code} (${format})`);
updateFeedback('detecting');
if (!detectionHistory[code]) detectionHistory[code] = { count: 0 };
detectionHistory[code].count++;
if (code === lastDetected) {
detectCount++;
} else {
lastDetected = code;
detectCount = 1;
}
if (detectCount >= 2 || detectionHistory[code].count >= 2) {
scanning = false;
quaggaRunning = false;
updateFeedback(null);
scanLog(`CONFIRMED: ${code} after ${frameCount} frames`);
onBarcodeDetected(code);
return;
}
} else {
updateFeedback('scanning');
}
} catch (e) {
scanLog(`Native detect error: ${e.message}`);
}
if (scanning) {
if (frameCount % 30 === 0) {
scanLog(`Native scanning... f${frameCount}, partials: ${partialCount}`);
}
requestAnimationFrame(scanFrame);
}
}
requestAnimationFrame(scanFrame);
}
// ===== QUAGGA FALLBACK SCANNER =====
function startQuaggaScanner(videoEl) {
if (quaggaRunning) return;
const canvas = document.getElementById('scanner-canvas');
const ctx = canvas.getContext('2d');
const frontCam = isFrontCamera();
const scannerLine = document.querySelector('.scanner-line');
let frameCount = 0;
let partialCount = 0;
scanLog(`Quagga starting — frontCam: ${frontCam}`);
let scanning = true;
quaggaRunning = true;
let lastDetected = '';
let detectCount = 0;
let detectionHistory = {};
// Alternate between full frame and center-cropped for better detection
let scanPass = 0; // 0=full, 1=center-crop, 2=full-enhanced, 3=center-enhanced
function updateScannerFeedback(state) {
if (!scannerLine) return;
scannerLine.classList.remove('scanning', 'detecting');
if (state) scannerLine.classList.add(state);
}
function getFrameDataUrl(pass) {
const vw = videoEl.videoWidth;
const vh = videoEl.videoHeight;
if (pass % 2 === 0) {
// Full frame
canvas.width = vw;
canvas.height = vh;
ctx.drawImage(videoEl, 0, 0);
} else {
// Center crop: 60% of frame, focused on barcode area
const cropW = Math.round(vw * 0.7);
const cropH = Math.round(vh * 0.4);
const sx = Math.round((vw - cropW) / 2);
const sy = Math.round((vh - cropH) / 2);
canvas.width = cropW;
canvas.height = cropH;
ctx.drawImage(videoEl, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
}
// Apply enhancement on passes 2,3 or always for front cam
if (frontCam || pass >= 2) {
enhanceCanvasForBarcode(ctx, canvas.width, canvas.height);
}
return canvas.toDataURL('image/jpeg', 0.95);
}
function scanFrame() {
if (!scanning || !scannerStream) return;
frameCount++;
scanPass = (scanPass + 1) % 4;
const dataUrl = getFrameDataUrl(scanPass);
if (frameCount === 1) {
scanLog(`Frame #1 — video: ${videoEl.videoWidth}x${videoEl.videoHeight}`);
updateScannerFeedback('scanning');
}
let callbackCalled = false;
const safetyTimer = setTimeout(() => {
if (!callbackCalled && scanning) {
scanLog(`Quagga timeout on f${frameCount}, retrying...`);
setTimeout(scanFrame, 100);
}
}, 5000);
try {
const imgSize = Math.max(canvas.width, canvas.height);
Quagga.decodeSingle({
src: dataUrl,
numOfWorkers: 0,
inputStream: { size: Math.min(imgSize, 800) },
decoder: {
readers: [
'ean_reader',
'ean_8_reader',
'code_128_reader',
'code_39_reader',
'upc_reader',
'upc_e_reader'
],
multiple: false
},
locate: true,
locator: { patchSize: 'large', halfSample: false }
}, function(result) {
callbackCalled = true;
clearTimeout(safetyTimer);
if (result && result.codeResult) {
const code = result.codeResult.code;
const format = result.codeResult.format;
partialCount++;
const passName = ['full','crop','full+enh','crop+enh'][scanPass];
scanLog(`Partial #${partialCount} [f${frameCount} ${passName}]: ${code} (${format})`);
updateScannerFeedback('detecting');
if (!detectionHistory[code]) detectionHistory[code] = { count: 0, lastFrame: 0 };
detectionHistory[code].count++;
detectionHistory[code].lastFrame = frameCount;
if (code === lastDetected) {
detectCount++;
} else {
lastDetected = code;
detectCount = 1;
}
const dominated = detectionHistory[code];
if (detectCount >= 2 || dominated.count >= 2) {
scanning = false;
quaggaRunning = false;
updateScannerFeedback(null);
scanLog(`CONFIRMED: ${code} after ${frameCount} frames (consec:${detectCount}, total:${dominated.count})`);
onBarcodeDetected(code);
return;
}
} else {
updateScannerFeedback('scanning');
}
if (scanning) {
if (frameCount % 20 === 0) {
scanLog(`Scanning... f${frameCount}, partials: ${partialCount}, pass: ${scanPass}`);
}
setTimeout(scanFrame, 150);
}
});
} catch (e) {
callbackCalled = true;
clearTimeout(safetyTimer);
scanLog(`Quagga error: ${e.message}`);
if (scanning) setTimeout(scanFrame, 500);
}
}
setTimeout(scanFrame, 500);
}
// Enhance low-quality camera frames for better barcode recognition
function enhanceCanvasForBarcode(ctx, w, h) {
const imageData = ctx.getImageData(0, 0, w, h);
const d = imageData.data;
// Convert to high-contrast grayscale
for (let i = 0; i < d.length; i += 4) {
// Luminance
let gray = 0.299 * d[i] + 0.587 * d[i+1] + 0.114 * d[i+2];
// Increase contrast
gray = ((gray - 128) * 1.5) + 128;
gray = gray < 0 ? 0 : gray > 255 ? 255 : gray;
// Threshold to make bars more distinct
gray = gray < 140 ? 0 : 255;
d[i] = d[i+1] = d[i+2] = gray;
}
ctx.putImageData(imageData, 0, 0);
}
function stopScanner() {
quaggaRunning = false;
if (scannerStream) {
scannerStream.getTracks().forEach(t => t.stop());
scannerStream = null;
}
const video = document.getElementById('scanner-video');
if (video) video.srcObject = null;
// Also stop AI camera
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
aiStream = null;
}
const aiVideo = document.getElementById('ai-video');
if (aiVideo) aiVideo.srcObject = null;
}
async function onBarcodeDetected(barcode) {
showLoading(true);
// Vibrate if available
if (navigator.vibrate) navigator.vibrate(100);
try {
// First check local DB
const localResult = await api('search_barcode', { barcode });
if (localResult.found) {
currentProduct = localResult.product;
// If product was saved with 'pz' but has weight info in notes, fix defaults
if (currentProduct.unit === 'pz' && currentProduct.default_quantity <= 1 && currentProduct.notes) {
const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/);
if (pesoMatch) {
const weightStr = pesoMatch[1].trim();
const detected = detectUnitAndQuantity(weightStr);
if (detected.unit !== 'pz') {
currentProduct.unit = detected.unit;
currentProduct.default_quantity = detected.quantity;
currentProduct.weight_info = weightStr;
// Update product in DB for future scans
api('product_save', {}, 'POST', {
id: currentProduct.id,
barcode: currentProduct.barcode,
name: currentProduct.name,
brand: currentProduct.brand || '',
category: currentProduct.category || '',
image_url: currentProduct.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
notes: currentProduct.notes,
});
}
}
}
// Extract weight_info from notes if available (stored as "Peso: 500 g · ...")
if (!currentProduct.weight_info && currentProduct.notes) {
const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/);
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
}
showLoading(false);
stopScanner();
showProductAction();
return;
}
// Lookup in external DB
const lookupResult = await api('lookup_barcode', { barcode });
if (lookupResult.found && lookupResult.product) {
const p = lookupResult.product;
// Detect unit and quantity from quantity_info
const detected = detectUnitAndQuantity(p.quantity_info);
// Build rich notes with all available info
const notesParts = [];
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
if (p.labels) notesParts.push(`Etichette: ${p.labels}`);
// Save to local DB
const saveResult = await api('product_save', {}, 'POST', {
barcode: barcode,
name: p.name || 'Prodotto sconosciuto',
brand: p.brand || '',
category: p.category || '',
image_url: p.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
notes: notesParts.join(' · '),
});
if (saveResult.id) {
currentProduct = {
id: saveResult.id,
barcode: barcode,
name: p.name || 'Prodotto sconosciuto',
brand: p.brand || '',
category: p.category || '',
image_url: p.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
weight_info: p.quantity_info || '',
nutriscore: p.nutriscore || '',
ingredients: p.ingredients || '',
allergens: p.allergens || '',
conservation: p.conservation || '',
origin: p.origin || '',
nova_group: p.nova_group || '',
ecoscore: p.ecoscore || '',
labels: p.labels || '',
stores: p.stores || '',
};
showLoading(false);
stopScanner();
showProductAction();
return;
}
}
// Not found - ask user to add manually
showLoading(false);
stopScanner();
showToast('Prodotto non trovato. Inseriscilo manualmente.', 'error');
startManualEntry(barcode);
} catch (err) {
showLoading(false);
console.error('Barcode lookup error:', err);
showToast('Errore nella ricerca. Riprova.', 'error');
}
}
function submitManualBarcode() {
const input = document.getElementById('manual-barcode-input');
const barcode = (input.value || '').trim();
if (!barcode) {
showToast('Inserisci un codice a barre', 'error');
input.focus();
return;
}
if (!/^\d{4,14}$/.test(barcode)) {
showToast('Il codice a barre deve contenere solo numeri (4-14 cifre)', 'error');
input.focus();
return;
}
stopScanner();
onBarcodeDetected(barcode);
}
// ===== QUICK NAME ENTRY (for loose/unpackaged products) =====
async function submitQuickName() {
const input = document.getElementById('quick-product-name');
const name = (input.value || '').trim();
if (!name || name.length < 2) {
showToast('Scrivi almeno 2 caratteri', 'error');
input.focus();
return;
}
stopScanner();
showLoading(true);
try {
// Search local products DB
const localData = await api('products_search', { q: name });
const localProducts = (localData.products || []).slice(0, 5);
showLoading(false);
if (localProducts.length > 0) {
// Show results to pick from + option to create new
showQuickNameResults(name, localProducts);
} else {
// No local results — create new product directly
await createQuickProduct(name);
}
} catch (err) {
showLoading(false);
console.error('Quick name search error:', err);
showToast('Errore nella ricerca', 'error');
}
}
function showQuickNameResults(searchName, products) {
const container = document.querySelector('.quick-name-entry');
// Remove any previous results
const oldResults = container.querySelector('.quick-name-results');
if (oldResults) oldResults.remove();
const resultsDiv = document.createElement('div');
resultsDiv.className = 'quick-name-results';
// Existing products
products.forEach(p => {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(p.category, p.name)] || '📦';
const item = document.createElement('div');
item.className = 'quick-name-result-item';
item.innerHTML = `
<span class="qnr-icon">${catIcon}</span>
<div class="qnr-info">
<div class="qnr-name">${escapeHtml(p.name)}</div>
<div class="qnr-detail">${p.brand ? escapeHtml(p.brand) + ' · ' : ''}${p.barcode ? '📊 ' + p.barcode : 'Senza barcode'}</div>
</div>
`;
item.onclick = () => selectQuickProduct(p);
resultsDiv.appendChild(item);
});
// "Create new" button
const newItem = document.createElement('div');
newItem.className = 'quick-name-result-item qnr-new';
newItem.innerHTML = `
<span class="qnr-icon"></span>
<div class="qnr-info">
<div class="qnr-name">Crea "${escapeHtml(searchName)}"</div>
<div class="qnr-detail">Nuovo prodotto senza barcode</div>
</div>
`;
newItem.onclick = () => createQuickProduct(searchName);
resultsDiv.appendChild(newItem);
container.appendChild(resultsDiv);
}
function selectQuickProduct(product) {
currentProduct = {
id: product.id,
barcode: product.barcode || '',
name: product.name,
brand: product.brand || '',
category: product.category || '',
image_url: product.image_url || '',
unit: product.unit || 'pz',
default_quantity: product.default_quantity || 1,
};
// Extract weight_info from notes if available
if (product.notes) {
const pesoMatch = product.notes.match(/Peso:\s*([^·]+)/);
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
}
clearQuickNameResults();
showProductAction();
}
async function createQuickProduct(name) {
showLoading(true);
// Auto-detect category from name
const category = guessCategoryFromName(name);
try {
const result = await api('product_save', {}, 'POST', {
name: name,
brand: '',
category: category,
unit: 'pz',
default_quantity: 1,
});
if (result.success || result.id) {
currentProduct = {
id: result.id,
name: name,
brand: '',
category: category,
unit: 'pz',
default_quantity: 1,
};
showLoading(false);
clearQuickNameResults();
showToast('Prodotto creato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast(result.error || 'Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
console.error('Quick product creation error:', err);
showToast('Errore di connessione', 'error');
}
}
function clearQuickNameResults() {
const container = document.querySelector('.quick-name-entry');
if (container) {
const results = container.querySelector('.quick-name-results');
if (results) results.remove();
}
const input = document.getElementById('quick-product-name');
if (input) input.value = '';
}
function startManualEntry(barcode = '') {
stopScanner();
// Reset form
document.getElementById('pf-id').value = '';
document.getElementById('pf-name').value = '';
document.getElementById('pf-brand').value = '';
document.getElementById('pf-category').value = '';
document.getElementById('pf-unit').value = 'pz';
document.getElementById('pf-defqty').value = '1';
document.getElementById('pf-notes').value = '';
document.getElementById('pf-barcode').value = barcode || '';
document.getElementById('pf-image').value = '';
document.getElementById('pf-image-preview').style.display = 'none';
document.getElementById('product-form-title').textContent = 'Nuovo Prodotto';
// Reset conf-size-row visibility
const pfConfRow = document.getElementById('pf-conf-size-row');
if (pfConfRow) pfConfRow.style.display = 'none';
document.getElementById('pf-conf-size').value = '';
document.getElementById('pf-conf-unit').value = 'g';
// Reset manual-edit tracking flags
document.getElementById('pf-category').dataset.manuallySet = 'false';
document.getElementById('pf-defqty').dataset.manuallySet = 'false';
// Track if user manually changes the quantity field
const qtyInput = document.getElementById('pf-defqty');
qtyInput.removeEventListener('input', markQtyManuallySet);
qtyInput.addEventListener('input', markQtyManuallySet);
// Auto-detect name → category when typing
const nameInput = document.getElementById('pf-name');
nameInput.removeEventListener('input', autoDetectCategory);
nameInput.addEventListener('input', autoDetectCategory);
showPage('product-form');
}
function markQtyManuallySet() {
document.getElementById('pf-defqty').dataset.manuallySet = 'true';
}
function autoDetectCategory() {
const name = document.getElementById('pf-name').value.toLowerCase();
if (name.length < 3) return;
const catSelect = document.getElementById('pf-category');
// Don't override if user already manually selected something
if (catSelect.dataset.manuallySet === 'true') return;
// Keywords → category mapping
const keyword2cat = {
'latte': 'latticini', 'yogurt': 'latticini', 'formaggio': 'latticini', 'mozzarella': 'latticini',
'burro': 'latticini', 'panna': 'latticini', 'ricotta': 'latticini', 'mascarpone': 'latticini',
'gorgonzola': 'latticini', 'parmigiano': 'latticini', 'grana': 'latticini', 'burrata': 'latticini',
'stracchino': 'latticini', 'uova': 'latticini',
'pollo': 'carne', 'manzo': 'carne', 'maiale': 'carne', 'vitello': 'carne', 'tacchino': 'carne',
'prosciutto': 'carne', 'salame': 'carne', 'bresaola': 'carne', 'mortadella': 'carne',
'wurstel': 'carne', 'macinato': 'carne', 'speck': 'carne',
'salmone': 'pesce', 'tonno': 'pesce', 'sgombro': 'pesce', 'pesce': 'pesce', 'merluzzo': 'pesce',
'mela': 'frutta', 'mele': 'frutta', 'banana': 'frutta', 'arancia': 'frutta', 'pera': 'frutta',
'fragola': 'frutta', 'uva': 'frutta', 'kiwi': 'frutta', 'limone': 'frutta',
'insalata': 'verdura', 'pomodor': 'verdura', 'zucchin': 'verdura', 'patat': 'verdura',
'cipoll': 'verdura', 'carota': 'verdura', 'spinaci': 'verdura', 'rucola': 'verdura',
'peperoni': 'verdura', 'melanzane': 'verdura', 'broccoli': 'verdura',
'pasta': 'pasta', 'spaghetti': 'pasta', 'penne': 'pasta', 'fusilli': 'pasta', 'riso': 'pasta',
'farina': 'pasta', 'rigatoni': 'pasta', 'farfalle': 'pasta',
'pane': 'pane', 'fette biscottate': 'pane', 'pancarrè': 'pane', 'pan carrè': 'pane',
'grissini': 'pane', 'crackers': 'pane', 'cracker': 'pane',
'surgelat': 'surgelati', 'findus': 'surgelati', 'gelato': 'surgelati',
'acqua': 'bevande', 'succo': 'bevande', 'birra': 'bevande', 'vino': 'bevande',
'coca cola': 'bevande', 'aranciata': 'bevande', 'tè': 'bevande', 'caffè': 'bevande',
'olio': 'condimenti', 'aceto': 'condimenti', 'sale': 'condimenti', 'pepe': 'condimenti',
'maionese': 'condimenti', 'ketchup': 'condimenti', 'senape': 'condimenti', 'zucchero': 'condimenti',
'biscott': 'snack', 'cioccolat': 'snack', 'nutella': 'snack', 'merendine': 'snack',
'patatine': 'snack', 'caramelle': 'snack',
'pelati': 'conserve', 'passata': 'conserve', 'legumi': 'conserve', 'ceci': 'conserve',
'fagioli': 'conserve', 'lenticchie': 'conserve', 'marmellata': 'conserve', 'miele': 'conserve',
'cereali': 'cereali', 'muesli': 'cereali', 'fiocchi': 'cereali',
};
for (const [keyword, cat] of Object.entries(keyword2cat)) {
if (name.includes(keyword)) {
catSelect.value = cat;
onCategoryChange(true);
return;
}
}
}
function onCategoryChange(fromAutoDetect = false) {
const cat = document.getElementById('pf-category').value;
const unitSelect = document.getElementById('pf-unit');
const qtyInput = document.getElementById('pf-defqty');
// If user manually changed category via dropdown, don't auto-fill qty/unit
if (!fromAutoDetect) {
// Mark qty as "set" so future auto-detects won't overwrite either
qtyInput.dataset.manuallySet = 'true';
return;
}
// Auto-detect from name: suggest default unit/qty based on category
// BUT only if user hasn't manually changed the quantity field
const catDefaults = {
'latticini': { unit: 'pz', qty: 1 },
'carne': { unit: 'g', qty: 500 },
'pesce': { unit: 'g', qty: 300 },
'frutta': { unit: 'kg', qty: 1 },
'verdura': { unit: 'kg', qty: 0.5 },
'pasta': { unit: 'g', qty: 500 },
'pane': { unit: 'pz', qty: 1 },
'surgelati': { unit: 'g', qty: 450 },
'bevande': { unit: 'l', qty: 1 },
'condimenti': { unit: 'pz', qty: 1 },
'snack': { unit: 'g', qty: 250 },
'conserve': { unit: 'g', qty: 400 },
'cereali': { unit: 'g', qty: 500 },
'igiene': { unit: 'pz', qty: 1 },
'pulizia': { unit: 'pz', qty: 1 },
};
if (catDefaults[cat]) {
// Only auto-fill unit/qty if user hasn't manually touched them
if (qtyInput.dataset.manuallySet !== 'true') {
unitSelect.value = catDefaults[cat].unit;
qtyInput.value = catDefaults[cat].qty;
}
}
}
function onPfUnitChange() {
const unit = document.getElementById('pf-unit').value;
const confRow = document.getElementById('pf-conf-size-row');
if (confRow) confRow.style.display = unit === 'conf' ? 'block' : 'none';
}
async function submitProduct(e) {
e.preventDefault();
showLoading(true);
const pfUnit = document.getElementById('pf-unit').value;
const productData = {
id: document.getElementById('pf-id').value || null,
name: document.getElementById('pf-name').value,
brand: document.getElementById('pf-brand').value,
category: document.getElementById('pf-category').value,
unit: pfUnit,
default_quantity: pfUnit === 'conf' ? (parseFloat(document.getElementById('pf-conf-size')?.value) || 1) : (parseFloat(document.getElementById('pf-defqty').value) || 1),
package_unit: pfUnit === 'conf' ? (document.getElementById('pf-conf-unit')?.value || '') : '',
notes: document.getElementById('pf-notes').value,
barcode: document.getElementById('pf-barcode').value || null,
image_url: document.getElementById('pf-image').value || '',
};
try {
const result = await api('product_save', {}, 'POST', productData);
if (result.success) {
currentProduct = { ...productData, id: result.id };
showLoading(false);
showToast('Prodotto salvato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast(result.error || 'Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== PRODUCT ACTION (IN/OUT) =====
function showProductAction() {
if (!currentProduct) return;
const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct.category, currentProduct.name)] || '📦';
const nutriscoreColors = { a: '#1e8f4e', b: '#60ac0e', c: '#eeae0e', d: '#ff6f1e', e: '#e63e11' };
let detailsHtml = '';
// Weight / quantity info
if (currentProduct.weight_info) {
detailsHtml += `<div class="product-detail-tag">⚖️ ${escapeHtml(currentProduct.weight_info)}</div>`;
}
// Nutriscore badge
if (currentProduct.nutriscore) {
const ns = currentProduct.nutriscore.toLowerCase();
const nsColor = nutriscoreColors[ns] || '#999';
detailsHtml += `<div class="product-detail-tag" style="background:${nsColor};color:#fff;font-weight:600">Nutri-Score ${ns.toUpperCase()}</div>`;
}
// NOVA group
if (currentProduct.nova_group) {
const novaLabels = { '1': 'Non trasformato', '2': 'Ingrediente culinario', '3': 'Trasformato', '4': 'Ultra-trasformato' };
detailsHtml += `<div class="product-detail-tag">🏭 NOVA ${currentProduct.nova_group}${novaLabels[currentProduct.nova_group] ? ' - ' + novaLabels[currentProduct.nova_group] : ''}</div>`;
}
// Ecoscore
if (currentProduct.ecoscore) {
const es = currentProduct.ecoscore.toLowerCase();
const esColor = nutriscoreColors[es] || '#999';
detailsHtml += `<div class="product-detail-tag" style="background:${esColor};color:#fff;font-weight:600">🌍 Eco-Score ${es.toUpperCase()}</div>`;
}
// Origin
if (currentProduct.origin) {
detailsHtml += `<div class="product-detail-tag">📍 ${escapeHtml(currentProduct.origin)}</div>`;
}
// Labels (bio, DOP, etc.)
if (currentProduct.labels) {
detailsHtml += `<div class="product-detail-tag">🏷️ ${escapeHtml(currentProduct.labels)}</div>`;
}
// Allergens
let allergensHtml = '';
if (currentProduct.allergens) {
allergensHtml = `<div class="product-allergens">⚠️ <strong>Allergeni:</strong> ${escapeHtml(currentProduct.allergens)}</div>`;
}
// Ingredients (collapsible)
let ingredientsHtml = '';
if (currentProduct.ingredients) {
ingredientsHtml = `
<details class="product-ingredients">
<summary>📋 Ingredienti</summary>
<p>${escapeHtml(currentProduct.ingredients)}</p>
</details>
`;
}
// Conservation
let conservationHtml = '';
if (currentProduct.conservation) {
conservationHtml = `<div class="product-conservation">🧊 ${escapeHtml(currentProduct.conservation)}</div>`;
}
// LARGER product preview
document.getElementById('action-product-preview').innerHTML = `
${currentProduct.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="">` :
`<span class="product-preview-emoji">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct.name)}</h3>
<p>${currentProduct.brand ? `<strong>${escapeHtml(currentProduct.brand)}</strong>` : ''}</p>
${currentProduct.weight_info ? `<p style="font-size:0.85rem;color:var(--text-light)">⚖️ ${escapeHtml(currentProduct.weight_info)}</p>` : ''}
${currentProduct.barcode ? `<p style="font-size:0.75rem;color:var(--text-muted)">📊 ${currentProduct.barcode}</p>` : ''}
</div>
`;
// Check if product needs editing (unknown name, missing info)
const isUnknown = !currentProduct.name ||
/sconosciuto|unknown|^$/i.test(currentProduct.name.trim()) ||
currentProduct.name.trim().length < 2;
const needsEdit = isUnknown || !currentProduct.brand;
// Edit product info section
let editInfoEl = document.getElementById('action-edit-info');
if (!editInfoEl) {
editInfoEl = document.createElement('div');
editInfoEl.id = 'action-edit-info';
const preview = document.getElementById('action-product-preview');
preview.parentElement.insertBefore(editInfoEl, preview.nextSibling);
}
if (needsEdit) {
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([key, label]) =>
`<option value="${key}" ${mapToLocalCategory(currentProduct.category, currentProduct.name) === key ? 'selected' : ''}>${label}</option>`
).join('');
editInfoEl.innerHTML = `
<div class="edit-unknown-card ${isUnknown ? 'highlight' : ''}">
<h4>${isUnknown ? '⚠️ Prodotto non riconosciuto' : '✏️ Completa le informazioni'}</h4>
<p class="edit-unknown-hint">${isUnknown ? 'Inserisci il nome e le informazioni del prodotto' : 'Puoi modificare o completare le info mancanti'}</p>
<div class="edit-unknown-form">
<div class="form-group">
<label>🏷️ Nome prodotto</label>
<input type="text" id="edit-action-name" class="form-input" value="${escapeHtml(isUnknown ? '' : currentProduct.name)}" placeholder="Es: Latte intero, Pasta penne..." required>
</div>
<div class="form-group">
<label>🏪 Marca</label>
<input type="text" id="edit-action-brand" class="form-input" value="${escapeHtml(currentProduct.brand || '')}" placeholder="Es: Barilla, Mulino Bianco...">
</div>
<div class="form-group">
<label>📂 Categoria</label>
<select id="edit-action-category" class="form-input">
<option value="">-- Seleziona --</option>
${categoryOptions}
</select>
</div>
<button type="button" class="btn btn-primary full-width" onclick="saveEditedProductInfo()">💾 Salva informazioni</button>
</div>
</div>
`;
editInfoEl.style.display = 'block';
if (isUnknown) {
setTimeout(() => document.getElementById('edit-action-name')?.focus(), 100);
}
} else {
editInfoEl.style.display = 'none';
editInfoEl.innerHTML = '';
}
// Show extra product info section below preview
let extraInfoEl = document.getElementById('action-product-details');
if (!extraInfoEl) {
const container = document.getElementById('action-product-preview').parentElement;
extraInfoEl = document.createElement('div');
extraInfoEl.id = 'action-product-details';
const actionBtns = document.getElementById('action-buttons-container');
actionBtns.parentElement.insertBefore(extraInfoEl, actionBtns);
}
if (detailsHtml || allergensHtml || ingredientsHtml || conservationHtml) {
extraInfoEl.innerHTML = `
<div class="product-details-card">
${detailsHtml ? `<div class="product-detail-tags">${detailsHtml}</div>` : ''}
${allergensHtml}
${ingredientsHtml}
${conservationHtml}
</div>
`;
extraInfoEl.style.display = 'block';
} else {
extraInfoEl.style.display = 'none';
extraInfoEl.innerHTML = '';
}
// === CHECK INVENTORY FOR THIS PRODUCT ===
checkInventoryForProduct(currentProduct.id).then(inventoryItems => {
const statusBar = document.getElementById('action-inventory-status');
const btnsContainer = document.getElementById('action-buttons-container');
if (inventoryItems.length > 0) {
// Product IS in inventory - show status and 3 buttons
statusBar.style.display = 'block';
let totalQty = 0;
const unit = inventoryItems[0].unit || 'pz';
const defQty = inventoryItems[0].default_quantity || 0;
const pkgUnit = inventoryItems[0].package_unit || '';
const invHtml = inventoryItems.map(inv => {
const locInfo = LOCATIONS[inv.location] || { icon: '📦', label: inv.location };
const qtyStr = formatQuantity(inv.quantity, inv.unit, inv.default_quantity, inv.package_unit);
const pkgF = formatPackageFraction(inv.quantity, inv.default_quantity);
totalQty += parseFloat(inv.quantity);
let expiryStr = '';
if (inv.expiry_date) {
const d = daysUntilExpiry(inv.expiry_date);
if (d < 0) expiryStr = ` · ⚠️ Scaduto da ${Math.abs(d)}g`;
else if (d <= 3) expiryStr = ` · 🔴 Scade tra ${d}g`;
else if (d <= 7) expiryStr = ` · 🟡 Scade tra ${d}g`;
else expiryStr = ` · 📅 ${formatDate(inv.expiry_date)}`;
}
return `<div class="inv-status-item"><span>${locInfo.icon} ${locInfo.label}${expiryStr}</span><span class="inv-status-qty">${qtyStr}${pkgF ? ' ' + pkgF : ''}</span></div>`;
}).join('');
const totalStr = formatQuantity(totalQty, unit, defQty, pkgUnit);
const totalFrac = formatPackageFraction(totalQty, defQty);
statusBar.innerHTML = `
<div class="inv-status-header">
<span class="inv-status-title">📦 Ce l'hai già!</span>
<div class="inv-status-total-col">
<span class="inv-status-total">${totalStr}</span>
${totalFrac ? `<span class="inv-status-total-frac">${totalFrac}</span>` : ''}
</div>
</div>
<div class="inv-status-items">${invHtml}</div>
`;
btnsContainer.className = 'action-buttons-3col';
btnsContainer.innerHTML = `
<button class="btn btn-huge btn-success" onclick="showAddForm()">
<span class="btn-icon">📥</span>
<span class="btn-text">AGGIUNGI<br><small>altra quantità</small></span>
</button>
<button class="btn btn-huge btn-danger" onclick="showUseForm()">
<span class="btn-icon">📤</span>
<span class="btn-text">USA<br><small>quanto ne hai usato</small></span>
</button>
<button class="btn btn-huge btn-throw" onclick="showThrowForm()">
<span class="btn-icon">🗑️</span>
<span class="btn-text">BUTTA<br><small>butta il prodotto</small></span>
</button>
`;
} else {
// Product NOT in inventory - show only AGGIUNGI
statusBar.style.display = 'none';
btnsContainer.className = 'action-buttons';
btnsContainer.innerHTML = `
<button class="btn btn-huge btn-success" onclick="showAddForm()" style="flex:1">
<span class="btn-icon">📥</span>
<span class="btn-text">AGGIUNGI<br><small>in dispensa/frigo</small></span>
</button>
`;
}
});
showPage('action');
}
// Check if product exists in inventory
async function checkInventoryForProduct(productId) {
try {
const data = await api('inventory_list');
return (data.inventory || []).filter(i => i.product_id == productId);
} catch(e) {
return [];
}
}
// === THROW AWAY FORM ===
function showThrowForm() {
// Open a modal to ask how much to throw away
api('inventory_list').then(data => {
const items = (data.inventory || []).filter(i => i.product_id == currentProduct.id);
if (items.length === 0) {
showToast('Prodotto non nell\'inventario', 'error');
return;
}
const totalQty = items.reduce((sum, i) => sum + parseFloat(i.quantity), 0);
const unit = items[0].unit || 'pz';
const defQty = items[0].default_quantity || 0;
const pkgUnit = items[0].package_unit || '';
const qtyDisplay = formatQuantity(totalQty, unit, defQty, pkgUnit);
let locOptionsHtml = items.map(inv => {
const locInfo = LOCATIONS[inv.location] || { icon: '📦', label: inv.location };
return `<div class="inv-status-item"><span>${locInfo.icon} ${locInfo.label}</span><span class="inv-status-qty">${formatQuantity(inv.quantity, inv.unit, inv.default_quantity, inv.package_unit)}</span></div>`;
}).join('');
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>🗑️ Butta Prodotto</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="product-preview-small" style="margin-bottom:12px">
${currentProduct.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="" style="width:50px;height:50px;border-radius:10px;object-fit:cover">` :
`<span style="font-size:2rem">${CATEGORY_ICONS[mapToLocalCategory(currentProduct.category, currentProduct.name)] || '📦'}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct.name)}</h3>
<p>Disponibile: <strong>${qtyDisplay}</strong></p>
</div>
</div>
<div class="inventory-status-bar" style="margin-bottom:16px">
<div class="inv-status-items">${locOptionsHtml}</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<button class="btn btn-large btn-danger full-width" onclick="throwAll()">
🗑️ Butta TUTTO (${qtyDisplay})
</button>
<div style="text-align:center;color:var(--text-muted);font-size:0.85rem">oppure specifica la quantità:</div>
<div class="form-group">
<label>📍 Da dove?</label>
<div class="location-selector" id="throw-location-selector">
${items.map((inv, idx) => {
const locInfo = LOCATIONS[inv.location] || { icon: '📦', label: inv.location };
return `<button type="button" class="loc-btn ${idx === 0 ? 'active' : ''}" onclick="selectThrowLocation(this, '${inv.location}')">${locInfo.icon} ${locInfo.label} (${formatQuantity(inv.quantity, inv.unit, inv.default_quantity, inv.package_unit)})</button>`;
}).join('')}
</div>
<input type="hidden" id="throw-location" value="${items[0].location}">
</div>
<div class="form-group">
<label>Quanto butti?</label>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', -1)"></button>
<input type="number" id="throw-quantity" value="1" min="0.1" step="any" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', 1)">+</button>
</div>
</div>
<button class="btn btn-large btn-warning full-width" onclick="throwPartial()">
🗑️ Butta questa quantità
</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
});
}
function selectThrowLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('throw-location').value = loc;
}
async function throwAll() {
closeModal();
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
use_all: true,
location: '__all__',
notes: 'Buttato'
});
showLoading(false);
if (result.success) {
showToast(`🗑️ ${currentProduct.name} buttato!`, 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch(e) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
async function throwPartial() {
const qty = parseFloat(document.getElementById('throw-quantity').value) || 1;
const loc = document.getElementById('throw-location').value;
closeModal();
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
quantity: qty,
location: loc,
notes: 'Buttato'
});
showLoading(false);
if (result.success) {
showToast(`🗑️ Buttato ${qty} ${currentProduct.unit || 'pz'} di ${currentProduct.name}`, 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch(e) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
async function saveEditedProductInfo() {
const name = (document.getElementById('edit-action-name')?.value || '').trim();
if (!name) {
showToast('Inserisci il nome del prodotto', 'error');
document.getElementById('edit-action-name')?.focus();
return;
}
const brand = (document.getElementById('edit-action-brand')?.value || '').trim();
const category = document.getElementById('edit-action-category')?.value || '';
showLoading(true);
try {
const result = await api('product_save', {}, 'POST', {
id: currentProduct.id,
barcode: currentProduct.barcode || null,
name: name,
brand: brand,
category: category || currentProduct.category || '',
image_url: currentProduct.image_url || '',
unit: currentProduct.unit || 'pz',
default_quantity: currentProduct.default_quantity || 1,
notes: currentProduct.notes || '',
});
showLoading(false);
if (result.success) {
// Update current product in memory
currentProduct.name = name;
currentProduct.brand = brand;
if (category) currentProduct.category = category;
showToast('✅ Prodotto aggiornato!', 'success');
// Refresh the action page with updated data
showProductAction();
} else {
showToast(result.error || 'Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== ADD TO INVENTORY =====
function showAddForm() {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct.category, currentProduct.name)] || '📦';
document.getElementById('add-product-preview').innerHTML = `
${currentProduct.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="">` :
`<span style="font-size:2rem">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct.name)}</h3>
<p>${currentProduct.brand ? escapeHtml(currentProduct.brand) : ''}</p>
${currentProduct.weight_info ? `<p style="font-size:0.8rem;color:var(--text-light)">${escapeHtml(currentProduct.weight_info)}</p>` : ''}
</div>
`;
// Set unit selector
const unit = currentProduct.unit || 'pz';
const unitSelect = document.getElementById('add-unit');
unitSelect.value = unit;
document.getElementById('add-quantity').value = unit === 'conf' ? (currentProduct.last_qty || 1) : (currentProduct.default_quantity || 1);
document.getElementById('add-quantity').dataset.manuallySet = 'false';
// Show/hide conf size row and pre-fill
const confRow = document.getElementById('add-conf-size-row');
if (confRow) {
confRow.style.display = unit === 'conf' ? 'block' : 'none';
if (unit === 'conf' && currentProduct.package_unit && currentProduct.default_quantity > 0) {
document.getElementById('add-conf-size').value = currentProduct.default_quantity;
document.getElementById('add-conf-unit').value = currentProduct.package_unit;
} else if (unit === 'conf') {
document.getElementById('add-conf-size').value = '';
document.getElementById('add-conf-unit').value = 'g';
}
}
// Track manual edits to quantity in add form
const addQtyInput = document.getElementById('add-quantity');
addQtyInput.removeEventListener('input', markAddQtyManuallySet);
addQtyInput.addEventListener('input', markAddQtyManuallySet);
// Show weight info if product has it
const weightInfoEl = document.getElementById('add-weight-info');
if (currentProduct.weight_info) {
weightInfoEl.textContent = `📦 Confezione: ${currentProduct.weight_info}`;
weightInfoEl.style.display = 'block';
} else {
weightInfoEl.style.display = 'none';
}
// Set qty step based on selected unit
updateAddQtyStep();
// Auto-detect location
const autoLoc = guessLocation(currentProduct);
document.getElementById('add-location').value = autoLoc;
// Highlight correct location button
document.querySelectorAll('#page-add .loc-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('#page-add .loc-btn').forEach(b => {
const btnText = b.textContent.toLowerCase();
if (btnText.includes(autoLoc)) b.classList.add('active');
});
// Show the purchase-type selector
const expirySection = document.getElementById('add-expiry-section');
const estimatedDays = estimateExpiryDays(currentProduct);
const estimatedDate = addDays(estimatedDays);
const estimateLabel = formatEstimatedExpiry(estimatedDays);
expirySection.innerHTML = `
<label>🛒 Questo prodotto è...</label>
<div class="purchase-type-selector">
<button type="button" class="purchase-type-btn active" onclick="selectPurchaseType(this, 'new', '${estimatedDate}', '${escapeHtml(estimateLabel)}')">
🆕 Appena comprato
</button>
<button type="button" class="purchase-type-btn" onclick="selectPurchaseType(this, 'existing', '', '')">
📦 Ce l'avevo già
</button>
</div>
<div id="expiry-detail" class="expiry-detail">
<div class="expiry-estimate">
<span class="expiry-estimate-label">Scadenza stimata: <strong>${estimateLabel}</strong></span>
<span class="expiry-estimate-date">${formatDate(estimatedDate)}</span>
</div>
<div class="expiry-input-row">
<input type="date" id="add-expiry" class="form-input" value="${estimatedDate}">
<button type="button" class="btn btn-accent btn-scan-expiry" onclick="scanExpiryWithAI()" title="Scansiona data scadenza">📷</button>
</div>
<p class="form-hint">📝 Puoi modificare la data o scansionarla con la fotocamera</p>
</div>
`;
showPage('add');
}
function onAddUnitChange() {
updateAddQtyStep();
const unit = document.getElementById('add-unit').value;
const qtyInput = document.getElementById('add-quantity');
// Show/hide conf size row
const confRow = document.getElementById('add-conf-size-row');
if (confRow) {
const isConf = unit === 'conf';
confRow.style.display = isConf ? 'block' : 'none';
// Pre-fill from currentProduct if available
if (isConf && currentProduct) {
const sizeInput = document.getElementById('add-conf-size');
const unitSelect = document.getElementById('add-conf-unit');
if (currentProduct.package_unit && currentProduct.default_quantity > 1) {
sizeInput.value = currentProduct.default_quantity;
unitSelect.value = currentProduct.package_unit;
} else {
sizeInput.value = '';
unitSelect.value = 'g';
}
}
// Scroll into view so the user sees the new field
if (isConf) setTimeout(() => confRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 100);
}
// If switching units, suggest a sensible quantity
// BUT only if the user hasn't manually changed the quantity in this form
if (qtyInput.dataset.manuallySet === 'true') return; // User already edited qty, don't overwrite
const currentQty = parseFloat(qtyInput.value) || 1;
// Convert between related units if logical
if (unit === 'g' && currentQty <= 10) qtyInput.value = currentProduct.weight_info ? parseFloat(currentProduct.weight_info) || 250 : 250;
if (unit === 'kg' && currentQty > 100) qtyInput.value = (currentQty / 1000).toFixed(1);
if (unit === 'ml' && currentQty <= 10) qtyInput.value = 500;
if (unit === 'l' && currentQty > 100) qtyInput.value = (currentQty / 1000).toFixed(1);
if (unit === 'pz' && currentQty > 100) qtyInput.value = 1;
if (unit === 'conf' && currentQty > 10) qtyInput.value = 1;
}
function updateAddQtyStep() {
const qtyInput = document.getElementById('add-quantity');
const unit = document.getElementById('add-unit').value;
qtyInput.step = 'any';
if (unit === 'g' || unit === 'ml') {
qtyInput.min = '1';
} else if (unit === 'kg' || unit === 'l') {
qtyInput.min = '0.1';
} else {
qtyInput.min = '1';
}
}
function markAddQtyManuallySet() {
document.getElementById('add-quantity').dataset.manuallySet = 'true';
}
function adjustAddQty(delta) {
const qtyInput = document.getElementById('add-quantity');
qtyInput.dataset.manuallySet = 'true'; // +/- buttons count as manual edit
const unit = document.getElementById('add-unit').value;
let val = parseFloat(qtyInput.value) || 0;
let step;
if (unit === 'kg' || unit === 'l') {
step = val < 1 ? 0.1 : 0.5;
} else if (unit === 'g' || unit === 'ml') {
step = val < 50 ? 1 : (val < 500 ? 10 : 50);
} else {
step = 1;
}
val = Math.max(parseFloat(qtyInput.min) || 0.1, val + delta * step);
// Round nicely
if (step >= 1) val = Math.round(val);
else val = Math.round(val * 10) / 10;
qtyInput.value = val;
}
function selectPurchaseType(btn, type, estimatedDate, estimateLabel) {
btn.parentElement.querySelectorAll('.purchase-type-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const detailDiv = document.getElementById('expiry-detail');
// Save current quantity before switching, so we can preserve it
const currentQty = document.getElementById('add-quantity').value;
if (type === 'new') {
detailDiv.innerHTML = `
<div class="expiry-estimate">
<span class="expiry-estimate-label">Scadenza stimata: <strong>${estimateLabel}</strong></span>
<span class="expiry-estimate-date">${formatDate(estimatedDate)}</span>
</div>
<div class="expiry-input-row">
<input type="date" id="add-expiry" class="form-input" value="${estimatedDate}">
<button type="button" class="btn btn-accent btn-scan-expiry" onclick="scanExpiryWithAI()" title="Scansiona data scadenza">📷</button>
</div>
<p class="form-hint">📝 Puoi modificare la data o scansionarla con la fotocamera</p>
`;
// Restore quantity - switching purchase type should NOT change it
document.getElementById('add-quantity').value = currentQty;
} else {
detailDiv.innerHTML = `
<div class="form-group">
<label>📅 Quando scade?</label>
<div class="expiry-input-row">
<input type="date" id="add-expiry" class="form-input" value="">
<button type="button" class="btn btn-accent btn-scan-expiry" onclick="scanExpiryWithAI()" title="Scansiona data scadenza">📷</button>
</div>
<p class="form-hint">Inserisci la data di scadenza o scansionala</p>
</div>
<div class="form-group">
<label>📦 Quantità rimasta</label>
<p class="form-hint" style="margin-bottom:6px">Quanto è rimasto approssimativamente?</p>
<div class="remaining-options">
<button type="button" class="remaining-btn" onclick="setRemainingPct(1)">🟢 Pieno</button>
<button type="button" class="remaining-btn" onclick="setRemainingPct(0.75)">🟡 ¾</button>
<button type="button" class="remaining-btn" onclick="setRemainingPct(0.5)">🟠 Metà</button>
<button type="button" class="remaining-btn" onclick="setRemainingPct(0.25)">🔴 ¼</button>
</div>
</div>
`;
// DON'T auto-set remaining percentage - keep the quantity the user already entered
}
}
function setRemainingPct(pct) {
document.querySelectorAll('.remaining-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
const baseQty = currentProduct.default_quantity || 1;
const unit = currentProduct.unit || 'pz';
let adjustedQty;
if (unit === 'pz' || unit === 'conf') {
adjustedQty = Math.max(1, Math.round(baseQty * pct));
} else {
adjustedQty = Math.round(baseQty * pct * 10) / 10;
}
document.getElementById('add-quantity').value = adjustedQty;
}
function selectLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('add-location').value = loc;
}
async function submitAdd(e) {
e.preventDefault();
showLoading(true);
try {
const selectedUnit = document.getElementById('add-unit').value;
const productUnit = currentProduct.unit || 'pz';
// Validate conf fields
if (selectedUnit === 'conf') {
const confSize = parseFloat(document.getElementById('add-conf-size')?.value);
if (!confSize || confSize <= 0) {
showLoading(false);
showToast('Specifica il contenuto di ogni confezione', 'error');
document.getElementById('add-conf-size')?.focus();
return;
}
}
const result = await api('inventory_add', {}, 'POST', {
product_id: currentProduct.id,
quantity: parseFloat(document.getElementById('add-quantity').value) || 1,
location: document.getElementById('add-location').value,
expiry_date: document.getElementById('add-expiry').value || null,
unit: selectedUnit !== productUnit ? selectedUnit : null,
package_unit: selectedUnit === 'conf' ? (document.getElementById('add-conf-unit')?.value || null) : null,
package_size: selectedUnit === 'conf' ? (parseFloat(document.getElementById('add-conf-size')?.value) || null) : null,
});
showLoading(false);
if (result.success) {
showToast(`✅ ${currentProduct.name} aggiunto!`, 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== USE FROM INVENTORY =====
function showUseForm() {
renderUsePreview();
document.getElementById('use-quantity').value = 1;
document.getElementById('use-location').value = 'dispensa';
// Reset location buttons
document.querySelectorAll('#page-use .loc-btn').forEach(b => b.classList.remove('active'));
document.querySelector('#page-use .loc-btn').classList.add('active');
loadUseInventoryInfo();
showPage('use');
}
function renderUsePreview() {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct?.category, currentProduct?.name)] || '📦';
document.getElementById('use-product-preview').innerHTML = `
${currentProduct?.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="">` :
`<span style="font-size:2rem">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct?.name || '')}</h3>
<p>${currentProduct?.brand ? escapeHtml(currentProduct.brand) : ''}</p>
</div>
`;
}
async function loadUseInventoryInfo() {
try {
const data = await api('inventory_list');
const items = (data.inventory || []).filter(i => i.product_id == currentProduct.id);
const infoEl = document.getElementById('use-inventory-info');
if (items.length > 0) {
infoEl.innerHTML = '<strong>📦 Disponibile:</strong> ' + items.map(i => {
const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location };
return `${loc.icon} ${loc.label}: ${i.quantity} ${i.unit}`;
}).join(' · ');
// Auto-select the first available location
const firstLoc = items[0].location;
document.getElementById('use-location').value = firstLoc;
document.querySelectorAll('#page-use .loc-btn').forEach(b => {
b.classList.remove('active');
if (b.getAttribute('onclick') && b.getAttribute('onclick').includes("'" + firstLoc + "'")) {
b.classList.add('active');
}
});
} else {
infoEl.innerHTML = '⚠️ Prodotto non presente nell\'inventario.';
}
} catch(e) {
console.error(e);
}
}
function selectUseLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('use-location').value = loc;
}
async function submitUseAll() {
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
use_all: true,
location: document.getElementById('use-location').value,
});
showLoading(false);
if (result.success) {
showToast(`📤 ${currentProduct.name} terminato!`, 'success');
if (result.added_to_bring) {
setTimeout(() => showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'), 1500);
}
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
async function submitUse(e) {
e.preventDefault();
showLoading(true);
try {
const qty = parseFloat(document.getElementById('use-quantity').value) || 1;
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
quantity: qty,
location: document.getElementById('use-location').value,
});
showLoading(false);
if (result.success) {
showToast(`📤 Usato ${qty} di ${currentProduct.name}. Rimasti: ${result.remaining}`, 'success');
if (result.added_to_bring) {
setTimeout(() => showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'), 1500);
}
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== AI IDENTIFICATION =====
async function captureForAI() {
stopScanner();
showPage('ai');
}
async function initAICamera() {
const video = document.getElementById('ai-video');
const captureDiv = document.getElementById('ai-capture');
const previewDiv = document.getElementById('ai-preview');
const captureBtn = document.getElementById('ai-capture-btn');
const retakeBtn = document.getElementById('ai-retake-btn');
const resultDiv = document.getElementById('ai-result');
captureDiv.style.display = 'block';
previewDiv.style.display = 'none';
captureBtn.style.display = 'block';
retakeBtn.style.display = 'none';
resultDiv.style.display = 'none';
try {
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
}
aiStream = await navigator.mediaDevices.getUserMedia(getCameraConstraints());
video.srcObject = aiStream;
await video.play();
} catch (err) {
console.error('AI Camera error:', err);
showToast('Impossibile accedere alla fotocamera', 'error');
}
}
function takePhotoForAI() {
const video = document.getElementById('ai-video');
const canvas = document.getElementById('ai-canvas');
const img = document.getElementById('ai-image');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
img.src = dataUrl;
// Stop camera
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
aiStream = null;
}
video.srcObject = null;
document.getElementById('ai-capture').style.display = 'none';
document.getElementById('ai-preview').style.display = 'block';
document.getElementById('ai-capture-btn').style.display = 'none';
document.getElementById('ai-retake-btn').style.display = 'block';
// Immediately start analysis
analyzeWithAI();
}
function retakePhotoAI() {
document.getElementById('ai-result').style.display = 'none';
initAICamera();
}
async function analyzeWithAI() {
const resultDiv = document.getElementById('ai-result');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div style="text-align:center;padding:20px"><div class="loading-spinner" style="margin:0 auto 12px"></div><p>🤖 Identifico il prodotto...</p></div>';
const canvas = document.getElementById('ai-canvas');
const base64 = canvas.toDataURL('image/jpeg', 0.7).split(',')[1];
try {
const result = await api('gemini_identify', {}, 'POST', { image: base64 });
if (!result.success) {
if (result.error === 'no_api_key') {
resultDiv.innerHTML = `<p style="color:var(--warning)">⚠️ Chiave API Gemini non configurata.<br><small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small></p>`;
} else {
resultDiv.innerHTML = `<p style="color:var(--danger)">❌ ${escapeHtml(result.error || 'Errore nell\'identificazione')}</p>
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">🔄 Riprova</button>`;
}
return;
}
const id = result.identified;
const matches = result.off_matches || [];
let html = `<h4>🤖 Prodotto identificato</h4>`;
html += `<div class="ai-identified-card">`;
html += `<strong>${escapeHtml(id.name)}</strong>`;
if (id.brand) html += ` <span style="color:var(--text-muted)">- ${escapeHtml(id.brand)}</span>`;
if (id.description) html += `<p style="font-size:0.85rem;color:var(--text-light);margin:4px 0 0">${escapeHtml(id.description)}</p>`;
html += `</div>`;
if (matches.length > 0) {
html += `<h4 style="margin-top:16px">📦 Prodotti corrispondenti</h4>`;
html += `<div class="ai-matches-list">`;
matches.forEach((m, idx) => {
html += `<div class="ai-match-item" onclick="selectAIMatch(${idx})">`;
if (m.image_url) {
html += `<img src="${m.image_url}" alt="" class="ai-match-img" onerror="this.style.display='none'">`;
}
html += `<div class="ai-match-info">`;
html += `<strong>${escapeHtml(m.name)}</strong>`;
if (m.brand) html += `<br><small>${escapeHtml(m.brand)}</small>`;
if (m.quantity_info) html += `<br><small style="color:var(--text-muted)">${escapeHtml(m.quantity_info)}</small>`;
html += `</div>`;
html += `<span class="ai-match-barcode">${m.barcode}</span>`;
html += `</div>`;
});
html += `</div>`;
}
// Option to save as-is without barcode
html += `<div style="margin-top:16px; border-top: 1px solid var(--bg-light); padding-top: 12px">`;
html += `<button class="btn btn-secondary full-width" onclick="saveAIProductDirect()">✏️ Salva senza barcode</button>`;
html += `</div>`;
resultDiv.innerHTML = html;
// Store data for later use
window._aiIdentified = id;
window._aiMatches = matches;
} catch (err) {
console.error('AI identify error:', err);
resultDiv.innerHTML = `<p style="color:var(--danger)">❌ Errore di connessione</p>
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">🔄 Riprova</button>`;
}
}
async function selectAIMatch(idx) {
const match = window._aiMatches[idx];
if (!match) return;
showLoading(true);
try {
// Use the barcode to do a full lookup (gets all details)
const localResult = await api('search_barcode', { barcode: match.barcode });
if (localResult.found) {
currentProduct = localResult.product;
showLoading(false);
showProductAction();
return;
}
// Full lookup via OpenFoodFacts
const lookupResult = await api('lookup_barcode', { barcode: match.barcode });
if (lookupResult.found && lookupResult.product) {
const p = lookupResult.product;
const detected = detectUnitAndQuantity(p.quantity_info);
const notesParts = [];
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
const saveResult = await api('product_save', {}, 'POST', {
barcode: match.barcode,
name: p.name || match.name,
brand: p.brand || match.brand || '',
category: p.category || '',
image_url: p.image_url || match.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
notes: notesParts.join(' · '),
});
if (saveResult.id) {
currentProduct = {
id: saveResult.id,
barcode: match.barcode,
name: p.name || match.name,
brand: p.brand || match.brand || '',
category: p.category || '',
image_url: p.image_url || match.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
weight_info: p.quantity_info || '',
};
showLoading(false);
showProductAction();
return;
}
}
// Fallback: save with basic info from match
const saveResult = await api('product_save', {}, 'POST', {
barcode: match.barcode,
name: match.name,
brand: match.brand || '',
category: match.category || '',
image_url: match.image_url || '',
unit: 'pz',
default_quantity: 1,
});
if (saveResult.id) {
currentProduct = { id: saveResult.id, barcode: match.barcode, name: match.name, brand: match.brand || '', category: match.category || '', image_url: match.image_url || '', unit: 'pz', default_quantity: 1 };
showLoading(false);
showProductAction();
} else {
showLoading(false);
showToast('Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
console.error('AI match select error:', err);
showToast('Errore di connessione', 'error');
}
}
async function saveAIProductDirect() {
const id = window._aiIdentified;
if (!id) return;
showLoading(true);
try {
const result = await api('product_save', {}, 'POST', {
name: id.name,
brand: id.brand || '',
category: id.category || '',
unit: 'pz',
default_quantity: 1,
});
if (result.success || result.id) {
currentProduct = { id: result.id, name: id.name, brand: id.brand || '', category: id.category || '', unit: 'pz', default_quantity: 1 };
showLoading(false);
showToast('Prodotto salvato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast(result.error || 'Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== ALL PRODUCTS =====
async function loadAllProducts() {
try {
const data = await api('products_list');
renderProductsList(data.products || []);
} catch (err) {
console.error(err);
}
}
async function searchAllProducts() {
const q = document.getElementById('products-search').value;
if (q.length < 2) {
loadAllProducts();
return;
}
const data = await api('products_search', { q });
renderProductsList(data.products || []);
}
function renderProductsList(products) {
const container = document.getElementById('products-list');
if (products.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📦</div><p>Nessun prodotto nel database.<br>Scansiona un prodotto per iniziare!</p></div>';
return;
}
container.innerHTML = products.map(p => {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(p.category, p.name)] || '📦';
return `
<div class="product-item" onclick="selectProductForAction(${p.id})">
<div class="inv-image">
${p.image_url ? `<img src="${escapeHtml(p.image_url)}" alt="" onerror="this.parentElement.innerHTML='${catIcon}'">` : catIcon}
</div>
<div class="inv-info">
<div class="inv-name">${escapeHtml(p.name)}</div>
${p.brand ? `<div class="inv-brand">${escapeHtml(p.brand)}</div>` : ''}
<div class="inv-meta">
${p.barcode ? `<span class="inv-badge" style="background:#f3f4f6;color:#374151">📊 ${p.barcode}</span>` : ''}
<span class="inv-badge" style="background:#f3f4f6;color:#374151">${catIcon} ${p.category || 'Non categorizzato'}</span>
</div>
</div>
</div>`;
}).join('');
}
async function selectProductForAction(productId) {
showLoading(true);
try {
const data = await api('product_get', { id: productId });
if (data.product) {
currentProduct = data.product;
showLoading(false);
showProductAction();
} else {
showLoading(false);
showToast('Prodotto non trovato', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore', 'error');
}
}
// ===== SHOPPING LIST (BRING! INTEGRATION) =====
let shoppingListUUID = '';
let shoppingItems = [];
let suggestionItems = [];
let shoppingPrices = {}; // { itemName: { product, searched: true } }
const DEFAULT_SPESA_AI_PROMPT = `Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare e una lista di prodotti trovati nel catalogo del supermercato.
Regole di selezione:
- Scegli il prodotto che corrisponde ESATTAMENTE a quello richiesto (stessa categoria merceologica)
- Preferisci prodotti freschi/sfusi rispetto a trasformati (es. "Arance" = arance frutta, NON aranciata bevanda)
- Se c'è una descrizione (es. "a cubetti", "biologico"), trova il prodotto che include quella caratteristica
- Se ci sono più varianti valide, scegli quella con il miglior rapporto qualità/prezzo
- Preferisci formati standard per una famiglia
- NON scegliere mai un prodotto di categoria diversa (bevanda vs frutta, surgelato vs fresco, condimento vs ortaggio, ecc.)
- "Finocchio" = ortaggio fresco, NON semi di finocchio o tisana
- "Arance" = frutta fresca, NON aranciata o succo
Rispondi SOLO con il numero (indice 0-based) del prodotto migliore, oppure -1 se nessun prodotto è appropriato.`;
function saveShoppingPrices() {
try {
// Only save items that have been searched (not loading state)
const toSave = {};
for (const [k, v] of Object.entries(shoppingPrices)) {
if (v.searched) toSave[k] = v;
}
localStorage.setItem('dispensa_shopping_prices', JSON.stringify(toSave));
} catch (e) { /* quota exceeded or private mode */ }
}
function loadShoppingPrices() {
try {
const raw = localStorage.getItem('dispensa_shopping_prices');
if (raw) shoppingPrices = JSON.parse(raw);
} catch (e) { shoppingPrices = {}; }
}
// Build a better search query from item name + specification
function buildSearchQuery(item) {
// Only use the item name for search - specification confuses the search engine
// The AI on the backend will use the specification to pick the right product
return item.name;
}
// Parse weight/quantity from specification (e.g. "200g" -> 0.2 kg, "500 ml" -> 0.5, "2 pz" -> 2 units)
function parseQtyFromSpec(spec) {
if (!spec) return null;
const s = spec.toLowerCase().trim();
// Match weight/volume: 200g, 0.5kg, 500 g, 1,5 kg, 200 gr
const m = s.match(/(\d+[.,]?\d*)\s*(g|gr|kg|ml|cl|l|lt)/i);
if (m) {
let val = parseFloat(m[1].replace(',', '.'));
const unit = m[2].toLowerCase();
if (unit === 'g' || unit === 'gr') return { kg: val / 1000, label: val + 'g', type: 'weight' };
if (unit === 'kg') return { kg: val, label: val + 'kg', type: 'weight' };
if (unit === 'ml') return { kg: val / 1000, label: val + 'ml', type: 'weight' };
if (unit === 'cl') return { kg: val / 100, label: val * 10 + 'ml', type: 'weight' };
if (unit === 'l' || unit === 'lt') return { kg: val, label: val + 'L', type: 'weight' };
}
// Match unit count: 2 pz, 3 pezzi, 5, 2x, ~5 pz
const pzMatch = s.match(/~?(\d+)\s*(pz|pezzi|x|$)/i);
if (pzMatch) {
const count = parseInt(pzMatch[1]);
if (count > 0 && count <= 50) return { count, label: count + ' pz', type: 'units' };
}
return null;
}
// Estimate price when product is sold per-kg/per-L or per-unit and user wants a certain quantity
function estimateItemPrice(product, spec) {
if (!product.priceUm) return null;
const umStr = String(product.priceUm);
const pm = umStr.match(/(\d+[.,]?\d*)/);
if (!pm) return null;
const pricePerUnit = parseFloat(pm[1].replace(',', '.'));
if (!pricePerUnit || pricePerUnit <= 0) return null;
const qty = parseQtyFromSpec(spec);
if (!qty) return null;
if (qty.type === 'weight') {
const estimated = pricePerUnit * qty.kg;
if (estimated <= 0 || estimated > 500) return null;
return { estimated: Math.round(estimated * 100) / 100, qtyLabel: qty.label };
} else if (qty.type === 'units') {
// For unit items: estimate per-item cost from the product price
// If product is per-kg and we want N pieces, estimate ~200-300g per piece
const avgWeightPerPiece = 0.25; // ~250g per piece (fruit/veg average)
const estimated = pricePerUnit * avgWeightPerPiece * qty.count;
if (estimated <= 0 || estimated > 500) return null;
return { estimated: Math.round(estimated * 100) / 100, qtyLabel: qty.label };
}
return null;
}
// Load just the shopping count for dashboard stat card
async function loadShoppingCount() {
try {
const data = await api('bring_list');
if (data.success && data.purchase) {
document.getElementById('stat-spesa').textContent = data.purchase.length;
} else {
document.getElementById('stat-spesa').textContent = '-';
}
} catch {
document.getElementById('stat-spesa').textContent = '-';
}
}
async function loadShoppingList() {
const statusEl = document.getElementById('bring-status');
const currentEl = document.getElementById('shopping-current');
const suggestionsEl = document.getElementById('shopping-suggestions');
statusEl.style.display = 'block';
statusEl.innerHTML = '<div class="bring-loading"><div class="loading-spinner"></div> Connessione a Bring!...</div>';
currentEl.style.display = 'none';
suggestionsEl.style.display = 'none';
try {
const data = await api('bring_list');
statusEl.style.display = 'none';
if (!data.success) {
statusEl.style.display = 'block';
statusEl.innerHTML = `<div class="bring-error">⚠️ ${escapeHtml(data.error || 'Errore connessione Bring!')}</div>`;
return;
}
shoppingListUUID = data.listUUID;
shoppingItems = data.purchase || [];
// Clean up shoppingPrices for items no longer on the list
const currentKeys = new Set(shoppingItems.map(i => i.name.toLowerCase()));
let pricesChanged = false;
for (const key of Object.keys(shoppingPrices)) {
if (!currentKeys.has(key)) {
delete shoppingPrices[key];
pricesChanged = true;
}
}
if (pricesChanged) saveShoppingPrices();
loadShoppingPrices();
renderShoppingItems();
currentEl.style.display = 'block';
} catch (err) {
console.error('Bring! error:', err);
statusEl.style.display = 'block';
statusEl.innerHTML = '<div class="bring-error">⚠️ Errore di connessione a Bring!</div>';
}
}
async function renderShoppingItems() {
const container = document.getElementById('shopping-items');
const countEl = document.getElementById('shopping-count');
countEl.textContent = shoppingItems.length;
if (shoppingItems.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:20px"><div class="empty-state-icon">✅</div><p>Lista della spesa vuota!<br>Usa il pulsante sotto per generare suggerimenti.</p></div>';
updateSpesaTotal();
return;
}
const s = getSettings();
let hasSpesa = s.spesa_logged_in && s.spesa_token;
// If not logged in locally, check server-side token
if (!hasSpesa) {
try {
const status = await api('dupliclick_status');
if (status.logged_in) {
hasSpesa = true;
s.spesa_logged_in = true;
s.spesa_token = 'server';
s.spesa_user = status.email || '';
saveSettings(s);
}
} catch (e) { /* ignore */ }
}
container.innerHTML = shoppingItems.map((item, idx) => {
const catIcon = CATEGORY_ICONS[guessCategoryFromName(item.name)] || '🛒';
const priceKey = item.name.toLowerCase();
const priceData = shoppingPrices[priceKey];
let detailHtml = '';
let priceTag = '';
let spesaBar = '';
if (hasSpesa) {
if (priceData && priceData.loading) {
detailHtml = `<div class="spesa-loading">🔍 Cerco...</div>`;
} else if (priceData && priceData.product) {
const p = priceData.product;
const promoHtml = p.promo
? `<span class="spesa-promo-badge">${escapeHtml(p.promo.label)} -${Math.round(p.promo.discountPerc)}%</span>`
: '';
const est = estimateItemPrice(p, item.specification || priceData.spec || '');
if (est) {
priceTag = `<div class="shopping-item-price">~€${est.estimated.toFixed(2)}</div>`;
} else {
priceTag = `<div class="shopping-item-price">€${p.price.toFixed(2)}</div>`;
}
detailHtml = `<div class="spesa-detail-left">
<span class="spesa-found-name">${escapeHtml(p.name)}</span>
<span class="spesa-pkg">${escapeHtml(p.packageDescr)}${est ? ' · ' + escapeHtml(String(p.priceUm || '')) + '/kg' : ''}</span>
${promoHtml}
</div>`;
spesaBar = `<div class="spesa-bar">
<button class="spesa-bar-btn" onclick="searchItemPrice(${idx}, true)" title="Ricerca">🔄 Ricerca</button>
<a href="${escapeHtml(p.url)}" target="_blank" class="spesa-bar-btn" title="${escapeHtml(p.name)} - ${escapeHtml(p.brand)}">🔗 Apri</a>
</div>`;
} else if (priceData && priceData.searched && !priceData.product) {
detailHtml = `<div class="spesa-detail-left"><span class="spesa-not-found">Non trovato</span></div>`;
spesaBar = `<div class="spesa-bar">
<button class="spesa-bar-btn" onclick="searchItemPrice(${idx}, true)" title="Riprova">🔄 Riprova</button>
</div>`;
} else {
spesaBar = `<div class="spesa-bar">
<button class="spesa-bar-btn" onclick="searchItemPrice(${idx})" title="Cerca prezzo">🔍 Cerca prezzo</button>
</div>`;
}
}
return `
<div class="shopping-item ${priceData && priceData.product && priceData.product.promo ? 'has-promo' : ''}" id="shop-item-${idx}">
<span class="shopping-item-icon">${catIcon}</span>
<div class="shopping-item-body">
<div class="shopping-item-top">
<div class="shopping-item-info">
<div class="shopping-item-name">${escapeHtml(item.name)}</div>
${item.specification ? `<div class="shopping-item-spec">${escapeHtml(item.specification)}</div>` : ''}
${detailHtml}
</div>
<div class="shopping-item-right">
${priceTag}
<button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="Rimuovi">✕</button>
</div>
</div>
${spesaBar}
</div>
</div>`;
}).join('');
updateSpesaTotal();
}
function updateSpesaTotal() {
const banner = document.getElementById('spesa-total-banner');
const valueEl = document.getElementById('spesa-total-value');
const detailEl = document.getElementById('spesa-total-detail');
let total = 0;
let found = 0;
let promoSaved = 0;
for (const item of shoppingItems) {
const pd = shoppingPrices[item.name.toLowerCase()];
if (pd && pd.product) {
const est = estimateItemPrice(pd.product, item.specification || pd.spec || '');
total += est ? est.estimated : pd.product.price;
found++;
if (pd.product.promo) {
promoSaved += pd.product.promo.discount;
}
}
}
if (found === 0) {
banner.style.display = 'none';
return;
}
banner.style.display = 'block';
valueEl.textContent = `€ ${total.toFixed(2)}`;
let detail = `${found}/${shoppingItems.length} prodotti trovati`;
if (promoSaved > 0) {
detail += ` · 🏷️ Risparmi €${promoSaved.toFixed(2)} con le offerte`;
}
detailEl.textContent = detail;
}
async function searchItemPrice(idx, force = false) {
const item = shoppingItems[idx];
if (!item) return;
const priceKey = item.name.toLowerCase();
const cached = shoppingPrices[priceKey];
// Invalidate cache if spec changed (e.g. item was updated in Bring)
if (!force && cached && cached.searched) {
const cachedSpec = (cached.spec || '').toLowerCase();
const currentSpec = (item.specification || '').toLowerCase();
if (cachedSpec === currentSpec) return;
}
const s = getSettings();
const provider = s.spesa_provider || 'dupliclick';
// Show loading state
shoppingPrices[priceKey] = { searched: false, loading: true, product: null };
renderShoppingItems();
try {
// Send item name as query, spec separately for AI selection
const searchQ = item.name;
const spec = item.specification || '';
const s2 = getSettings();
const aiPrompt = s2.spesa_ai_prompt || '';
const res = await api(`${provider}_search`, {
q: searchQ,
spec: spec,
prompt: aiPrompt
});
if (res.success && res.product) {
shoppingPrices[priceKey] = { searched: true, product: res.product, spec: item.specification || '' };
} else {
shoppingPrices[priceKey] = { searched: true, product: null };
}
} catch (e) {
shoppingPrices[priceKey] = { searched: true, product: null };
}
saveShoppingPrices();
renderShoppingItems();
}
async function searchAllPrices() {
const s = getSettings();
if (!s.spesa_logged_in && !s.spesa_token) {
// Try server-side check
try {
const status = await api('dupliclick_status');
if (!status.logged_in) {
showToast('Configura prima la Spesa Online nelle impostazioni', 'error');
return;
}
s.spesa_logged_in = true;
s.spesa_token = 'server';
saveSettings(s);
} catch (e) {
showToast('Configura prima la Spesa Online nelle impostazioni', 'error');
return;
}
}
const btn = document.getElementById('btn-search-prices');
const toSearch = shoppingItems.filter(item => {
const pd = shoppingPrices[item.name.toLowerCase()];
return !pd || !pd.searched;
});
if (toSearch.length === 0) {
showToast('Tutti i prodotti sono già stati cercati. Usa 🔄 per ricercare singoli.', 'info');
return;
}
btn.disabled = true;
const totalToSearch = toSearch.length;
for (let i = 0; i < toSearch.length; i++) {
const item = toSearch[i];
btn.innerHTML = `⏳ Cerco ${i + 1}/${totalToSearch}...`;
const priceKey = item.name.toLowerCase();
const provider = s.spesa_provider || 'dupliclick';
try {
const aiPrompt = s.spesa_ai_prompt || '';
const res = await api(`${provider}_search`, {
q: item.name,
spec: item.specification || '',
prompt: aiPrompt
});
if (res.success && res.product) {
shoppingPrices[priceKey] = { searched: true, product: res.product, spec: item.specification || '' };
} else {
shoppingPrices[priceKey] = { searched: true, product: null };
}
} catch (e) {
shoppingPrices[priceKey] = { searched: true, product: null };
}
saveShoppingPrices();
renderShoppingItems();
// Small delay to not overwhelm the API
if (i < toSearch.length - 1) {
await new Promise(r => setTimeout(r, 300));
}
}
btn.disabled = false;
btn.innerHTML = '🔍 Cerca tutti i prezzi';
showToast(`Ricerca completata: ${totalToSearch} prodotti`, 'success');
}
async function removeBringItem(idx) {
const item = shoppingItems[idx];
if (!item) return;
try {
const data = await api('bring_remove', {}, 'POST', {
name: item.name,
rawName: item.rawName || '',
listUUID: shoppingListUUID
});
if (data.success) {
shoppingItems.splice(idx, 1);
renderShoppingItems();
showToast('Rimosso dalla lista', 'success');
// Update dashboard shopping count
loadShoppingCount();
}
} catch (err) {
showToast('Errore nella rimozione', 'error');
}
}
async function generateSuggestions() {
const btn = document.getElementById('btn-suggest');
const suggestionsEl = document.getElementById('shopping-suggestions');
btn.disabled = true;
btn.innerHTML = '<div class="loading-spinner" style="display:inline-block;width:18px;height:18px;margin-right:8px;vertical-align:middle"></div> Analisi in corso...';
suggestionsEl.style.display = 'none';
try {
const data = await api('bring_suggest', {}, 'POST', {});
btn.disabled = false;
btn.innerHTML = '🤖 Suggerisci cosa comprare';
if (!data.success) {
showToast(data.error || 'Errore nella generazione', 'error');
return;
}
suggestionItems = (data.suggestions || []).map(s => ({ ...s, selected: true }));
// Show seasonal tip
const tipEl = document.getElementById('seasonal-tip');
if (data.seasonal_tip) {
tipEl.style.display = 'block';
tipEl.innerHTML = `🌿 <em>${escapeHtml(data.seasonal_tip)}</em>`;
} else {
tipEl.style.display = 'none';
}
renderSuggestions();
suggestionsEl.style.display = 'block';
document.getElementById('suggestion-actions').style.display = 'block';
// Scroll to suggestions
suggestionsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (err) {
btn.disabled = false;
btn.innerHTML = '🤖 Suggerisci cosa comprare';
console.error('Suggestion error:', err);
showToast('Errore di connessione', 'error');
}
}
function renderSuggestions() {
const container = document.getElementById('suggestion-items');
const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 };
const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2));
container.innerHTML = sorted.map((item, idx) => {
const catIcon = CATEGORY_ICONS[item.category] || '🛒';
const priorityBadge = {
'alta': '<span class="priority-badge priority-high">Alta</span>',
'media': '<span class="priority-badge priority-med">Media</span>',
'bassa': '<span class="priority-badge priority-low">Bassa</span>',
}[item.priority] || '';
return `
<div class="suggestion-item ${item.selected ? 'selected' : ''}" onclick="toggleSuggestion(${idx})">
<div class="suggestion-check">${item.selected ? '☑️' : '⬜'}</div>
<span class="shopping-item-icon">${catIcon}</span>
<div class="suggestion-info">
<div class="suggestion-name">${escapeHtml(item.name)}${item.specification ? ` <small>(${escapeHtml(item.specification)})</small>` : ''} ${priorityBadge}</div>
<div class="suggestion-reason">${escapeHtml(item.reason)}</div>
</div>
</div>`;
}).join('');
updateSuggestionActionBtn();
}
function toggleSuggestion(idx) {
const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 };
const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2));
const actualItem = sorted[idx];
// Find in original array
const origIdx = suggestionItems.indexOf(actualItem);
if (origIdx >= 0) {
suggestionItems[origIdx].selected = !suggestionItems[origIdx].selected;
}
renderSuggestions();
}
function updateSuggestionActionBtn() {
const selected = suggestionItems.filter(s => s.selected);
const btn = document.querySelector('#suggestion-actions .btn-success');
if (btn) {
btn.textContent = `✅ Aggiungi ${selected.length} prodott${selected.length === 1 ? 'o' : 'i'} a Bring!`;
btn.disabled = selected.length === 0;
}
}
async function addSelectedSuggestions() {
const selected = suggestionItems.filter(s => s.selected);
if (selected.length === 0) {
showToast('Seleziona almeno un prodotto', 'error');
return;
}
const btn = document.querySelector('#suggestion-actions .btn-success');
btn.disabled = true;
btn.innerHTML = '<div class="loading-spinner" style="display:inline-block;width:18px;height:18px;margin-right:8px;vertical-align:middle"></div> Aggiunta in corso...';
try {
const items = selected.map(s => {
// Build rich specification: combine quantity/detail + priority emoji
let spec = s.specification || '';
const priorityEmoji = { 'alta': '🔴', 'media': '🟡', 'bassa': '🟢' };
const emoji = priorityEmoji[s.priority] || '';
if (emoji) {
spec = spec ? `${emoji} ${spec}` : emoji;
}
return { name: s.name, specification: spec };
});
const data = await api('bring_add', {}, 'POST', { items, listUUID: shoppingListUUID });
if (data.success) {
let msg = `${data.added} prodott${data.added === 1 ? 'o aggiunto' : 'i aggiunti'} a Bring!`;
if (data.skipped > 0) msg += ` (${data.skipped} già in lista)`;
showToast(msg, 'success');
// Refresh list
await loadShoppingList();
// Update dashboard shopping count
loadShoppingCount();
// Clear suggestions
document.getElementById('shopping-suggestions').style.display = 'none';
suggestionItems = [];
} else {
showToast(data.error || 'Errore', 'error');
}
} catch (err) {
showToast('Errore di connessione', 'error');
}
btn.disabled = false;
btn.innerHTML = '✅ Aggiungi selezionati a Bring!';
}
// ===== UTILITY FUNCTIONS =====
// ===== SCAN EXPIRY DATE WITH CAMERA + GEMINI AI =====
let expiryStream = null;
async function scanExpiryWithAI() {
// Create modal for camera capture
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>📷 Scansiona Data Scadenza</h3>
<button class="modal-close" onclick="closeExpiryScanner()">✕</button>
</div>
<div class="expiry-scanner">
<div id="expiry-cam-container" style="height:180px;overflow:hidden;border-radius:10px;position:relative">
<video id="expiry-video" autoplay playsinline style="width:100%;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) scale(2);transform-origin:center center"></video>
<canvas id="expiry-canvas" style="display:none"></canvas>
<div style="position:absolute;inset:0;border:2px dashed rgba(255,255,255,0.5);border-radius:10px;pointer-events:none"></div>
</div>
<div id="expiry-preview-container" style="display:none;height:180px;overflow:hidden;border-radius:10px">
<img id="expiry-preview-img" src="" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:10px">
</div>
<p class="form-hint" style="text-align:center;margin:6px 0;font-size:0.8rem">Inquadra la data di scadenza stampata sul prodotto</p>
<div id="expiry-scan-status" style="display:none;text-align:center;padding:8px">
<div class="loading-spinner" style="margin:0 auto 6px"></div>
<p>🤖 Analisi AI in corso...</p>
</div>
<div class="expiry-scanner-actions">
<button class="btn btn-large btn-accent full-width" id="expiry-capture-btn" onclick="captureExpiry()">📸 Scatta Foto</button>
<button class="btn btn-large btn-secondary full-width" id="expiry-retake-btn" onclick="retakeExpiry()" style="display:none">🔄 Riscatta</button>
</div>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
// Start camera
try {
expiryStream = await navigator.mediaDevices.getUserMedia(getCameraConstraints());
const video = document.getElementById('expiry-video');
video.srcObject = expiryStream;
await video.play();
} catch (err) {
console.error('Expiry camera error:', err);
document.getElementById('expiry-cam-container').innerHTML = `
<p style="color:var(--danger);text-align:center;padding:20px">⚠️ Impossibile accedere alla fotocamera</p>
`;
}
}
function closeExpiryScanner() {
if (expiryStream) {
expiryStream.getTracks().forEach(t => t.stop());
expiryStream = null;
}
closeModal();
}
function captureExpiry() {
const video = document.getElementById('expiry-video');
const canvas = document.getElementById('expiry-canvas');
const img = document.getElementById('expiry-preview-img');
// Crop to center 50% (matching the 2x zoom view) for better AI accuracy
const sw = video.videoWidth / 2;
const sh = video.videoHeight / 2;
const sx = (video.videoWidth - sw) / 2;
const sy = (video.videoHeight - sh) / 2;
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, sw, sh);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
img.src = dataUrl;
// Stop camera
if (expiryStream) {
expiryStream.getTracks().forEach(t => t.stop());
expiryStream = null;
}
video.srcObject = null;
document.getElementById('expiry-cam-container').style.display = 'none';
document.getElementById('expiry-preview-container').style.display = 'block';
document.getElementById('expiry-capture-btn').style.display = 'none';
document.getElementById('expiry-retake-btn').style.display = 'block';
// Auto-analyze
analyzeExpiryImage(dataUrl);
}
function retakeExpiry() {
document.getElementById('expiry-cam-container').style.display = 'block';
document.getElementById('expiry-preview-container').style.display = 'none';
document.getElementById('expiry-capture-btn').style.display = 'block';
document.getElementById('expiry-retake-btn').style.display = 'none';
document.getElementById('expiry-scan-status').style.display = 'none';
// Restart camera
navigator.mediaDevices.getUserMedia(getCameraConstraints()).then(stream => {
expiryStream = stream;
const video = document.getElementById('expiry-video');
video.srcObject = stream;
video.play();
}).catch(err => console.error(err));
}
async function analyzeExpiryImage(dataUrl) {
const statusDiv = document.getElementById('expiry-scan-status');
statusDiv.style.display = 'block';
statusDiv.innerHTML = '<div class="loading-spinner" style="margin:0 auto 8px"></div><p>🤖 Analisi AI in corso...</p>';
try {
// Remove data:image/jpeg;base64, prefix
const base64 = dataUrl.split(',')[1];
const result = await api('gemini_expiry', {}, 'POST', { image: base64 });
if (result.success && result.expiry_date) {
// Auto-fill the expiry date
const expiryInput = document.getElementById('add-expiry');
if (expiryInput) {
expiryInput.value = result.expiry_date;
}
statusDiv.innerHTML = `<p style="color:var(--success);font-weight:600">✅ Data trovata: ${formatDate(result.expiry_date)}</p>`;
// Close modal after delay
setTimeout(() => closeExpiryScanner(), 1500);
} else if (result.error === 'no_api_key') {
statusDiv.innerHTML = `<p style="color:var(--warning)">⚠️ Chiave API Gemini non configurata.<br><small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small></p>`;
} else {
statusDiv.innerHTML = `<p style="color:var(--danger)">❌ Non riesco a leggere la data. ${result.raw_text ? '<br><small>Letto: ' + escapeHtml(result.raw_text) + '</small>' : ''}</p>
<button class="btn btn-secondary" onclick="retakeExpiry()" style="margin-top:8px">🔄 Riprova</button>`;
}
} catch (err) {
console.error('Expiry AI error:', err);
statusDiv.innerHTML = `<p style="color:var(--danger)">❌ Errore di connessione. Riprova.</p>
<button class="btn btn-secondary" onclick="retakeExpiry()" style="margin-top:8px">🔄 Riprova</button>`;
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
}
function formatDateTime(dtStr) {
if (!dtStr) return '';
const d = new Date(dtStr.replace(' ', 'T'));
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short' }) + ' ' +
d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
}
function daysUntilExpiry(dateStr) {
if (!dateStr) return Infinity;
const expiry = new Date(dateStr + 'T00:00:00');
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.round((expiry - today) / 86400000);
}
function adjustQty(inputId, delta) {
const input = document.getElementById(inputId);
let val = parseFloat(input.value) || 0;
val = Math.max(0.1, val + delta);
input.value = Math.round(val * 10) / 10;
}
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'flex' : 'none';
}
function showToast(message, type = '') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show ' + type;
setTimeout(() => {
toast.className = 'toast';
}, 3000);
}
// ===== LOG =====
let _logOffset = 0;
const LOG_PAGE_SIZE = 50;
async function loadLog(more = false) {
if (!more) {
_logOffset = 0;
document.getElementById('log-list').innerHTML = '<p style="text-align:center;color:var(--text-muted)">Caricamento...</p>';
}
try {
const result = await api(`transactions_list&limit=${LOG_PAGE_SIZE}&offset=${_logOffset}`);
const txns = result.transactions || [];
let html = '';
if (!more && txns.length === 0) {
html = '<p style="text-align:center;color:var(--text-muted)">Nessuna operazione registrata.</p>';
} else {
let lastDate = more ? '' : null;
txns.forEach(t => {
const dt = new Date(t.created_at + 'Z');
const dateStr = dt.toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const timeStr = dt.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
if (dateStr !== lastDate) {
html += `<div class="log-date-header">${dateStr}</div>`;
lastDate = dateStr;
}
let icon, typeLabel, colorClass;
if (t.type === 'bring') {
icon = '🛒';
typeLabel = 'Aggiunto a Bring!';
colorClass = 'log-bring';
} else if (t.type === 'in') {
icon = '';
typeLabel = 'Aggiunto';
colorClass = 'log-in';
} else {
icon = '';
typeLabel = 'Usato';
colorClass = 'log-out';
}
const brand = t.brand ? ` <em>(${t.brand})</em>` : '';
const loc = t.location || '';
const locLabels = { 'frigo': '🧊 Frigo', 'freezer': '❄️ Freezer', 'dispensa': '🗄️ Dispensa' };
const locStr = t.type === 'bring' ? '' : (locLabels[loc] || ('📍 ' + loc));
const notes = t.notes ? ` · ${t.notes}` : '';
html += `<div class="log-entry ${colorClass}">`;
html += `<span class="log-icon">${icon}</span>`;
html += `<div class="log-info">`;
html += `<div class="log-product"><strong>${t.name}</strong>${brand}</div>`;
html += `<div class="log-detail">${typeLabel} ${t.type !== 'bring' ? t.quantity + ' ' + (t.unit || '') + ' · ' : ''}${locStr}${notes} · ${timeStr}</div>`;
html += `</div>`;
html += `</div>`;
});
}
if (more) {
document.getElementById('log-list').insertAdjacentHTML('beforeend', html);
} else {
document.getElementById('log-list').innerHTML = html;
}
_logOffset += txns.length;
document.getElementById('log-load-more').style.display = txns.length >= LOG_PAGE_SIZE ? '' : 'none';
} catch (err) {
console.error('Log load error:', err);
if (!more) document.getElementById('log-list').innerHTML = '<p style="text-align:center;color:var(--danger)">Errore nel caricamento log</p>';
}
}
// ===== RECIPE GENERATION =====
function getMealType() {
const hour = new Date().getHours();
if (hour >= 5 && hour < 11) return 'colazione';
if (hour >= 11 && hour < 16) return 'pranzo';
return 'cena';
}
const MEAL_LABELS = {
'colazione': '☀️ Colazione',
'pranzo': '🍽️ Pranzo',
'cena': '🌙 Cena'
};
// ===== RECIPE ARCHIVE =====
function getRecipeArchive() {
try {
return JSON.parse(localStorage.getItem('dispensa_recipe_archive') || '[]');
} catch { return []; }
}
function saveRecipeToArchive(recipe) {
const archive = getRecipeArchive();
const today = new Date().toISOString().slice(0, 10);
archive.unshift({ date: today, meal: recipe.meal, recipe, savedAt: Date.now() });
// Keep max 60 recipes
if (archive.length > 60) archive.length = 60;
localStorage.setItem('dispensa_recipe_archive', JSON.stringify(archive));
}
function getTodayRecipeTitles() {
const archive = getRecipeArchive();
const today = new Date().toISOString().slice(0, 10);
return archive
.filter(e => e.date === today && e.recipe && e.recipe.title)
.map(e => e.recipe.title);
}
let _recipeArchiveEntries = [];
function loadRecipeArchive() {
const container = document.getElementById('recipe-archive');
if (!container) return;
const archive = getRecipeArchive();
_recipeArchiveEntries = archive;
if (archive.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:20px"><div class="empty-state-icon">🍳</div><p>Nessuna ricetta salvata.<br>Genera la tua prima ricetta!</p></div>';
return;
}
// Group by date
const byDate = {};
for (const entry of archive) {
if (!byDate[entry.date]) byDate[entry.date] = [];
byDate[entry.date].push(entry);
}
let html = '';
let flatIdx = 0;
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
for (const [date, entries] of Object.entries(byDate)) {
let dateLabel = new Date(date + 'T12:00:00').toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long' });
if (date === today) dateLabel = '📅 Oggi';
else if (date === yesterday) dateLabel = '📅 Ieri';
html += `<div class="recipe-archive-day">`;
html += `<div class="recipe-archive-date">${escapeHtml(dateLabel)}</div>`;
for (const entry of entries) {
const r = entry.recipe;
const mealIcon = MEAL_LABELS[r.meal] || r.meal;
const tags = (r.tags || []).slice(0, 3).join(', ');
// Find this entry's index in the flat archive array
const archiveIdx = archive.indexOf(entry);
html += `<div class="recipe-archive-card" onclick="viewArchivedRecipe(${archiveIdx})">`;
html += `<div class="recipe-archive-card-header">`;
html += `<span class="recipe-archive-meal">${mealIcon}</span>`;
html += `<span class="recipe-archive-title">${escapeHtml(r.title)}</span>`;
html += `</div>`;
html += `<div class="recipe-archive-card-meta">`;
if (r.prep_time) html += `<span>🔪 ${r.prep_time}</span>`;
if (r.cook_time) html += `<span>🔥 ${r.cook_time}</span>`;
html += `<span>👥 ${r.persons}</span>`;
if (tags) html += `<span>${tags}</span>`;
html += `</div></div>`;
flatIdx++;
}
html += `</div>`;
}
container.innerHTML = html;
}
function viewArchivedRecipe(idx) {
const entry = _recipeArchiveEntries[idx];
if (!entry) return;
renderRecipe(entry.recipe);
document.getElementById('recipe-overlay').style.display = 'flex';
document.getElementById('recipe-ask').style.display = 'none';
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-result').style.display = '';
}
function openRecipeDialog() {
const meal = getMealType();
const settings = getSettings();
document.getElementById('recipe-meal-title').textContent = MEAL_LABELS[meal] || '🍳 Ricetta';
document.getElementById('recipe-overlay').style.display = 'flex';
// Check for cached recipe matching current meal type
try {
const cached = JSON.parse(localStorage.getItem('cachedRecipe') || 'null');
if (cached && cached.meal === meal && cached.recipe) {
document.getElementById('recipe-ask').style.display = 'none';
document.getElementById('recipe-loading').style.display = 'none';
renderRecipe(cached.recipe);
document.getElementById('recipe-result').style.display = '';
return;
}
} catch (e) { /* ignore parse errors */ }
// Pre-fill persons from settings
document.getElementById('recipe-persons').value = settings.default_persons || 1;
// Pre-select option chips from settings
const prefMap = {
'veloce': 'recipe-opt-veloce',
'pocafame': 'recipe-opt-pocafame',
'scadenze': 'recipe-opt-scadenze',
'salutare': 'recipe-opt-healthy',
'comfort': 'recipe-opt-comfort',
'zerowaste': 'recipe-opt-zerowaste'
};
Object.entries(prefMap).forEach(([key, id]) => {
const cb = document.getElementById(id);
if (cb) cb.checked = settings.recipe_prefs && settings.recipe_prefs.includes(key);
});
document.getElementById('recipe-ask').style.display = '';
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-result').style.display = 'none';
}
// Toggle recipe option chip
function toggleRecipeOption(btn) {
btn.classList.toggle('active');
}
function closeRecipeDialog() {
document.getElementById('recipe-overlay').style.display = 'none';
}
function adjustRecipePersons(delta) {
const input = document.getElementById('recipe-persons');
let val = parseInt(input.value) || 1;
val = Math.max(1, Math.min(20, val + delta));
input.value = val;
}
async function useRecipeIngredient(idx, productId, location, qtyNumber, btn) {
if (btn.disabled) return;
if (!qtyNumber || qtyNumber <= 0) qtyNumber = 1;
btn.disabled = true;
btn.textContent = '⏳...';
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: productId,
quantity: qtyNumber,
use_all: false,
location: location
});
if (result.success) {
const li = document.getElementById(`recipe-ing-${idx}`);
if (li) {
li.classList.add('recipe-ing-used');
}
btn.textContent = '✔️ Scalato';
btn.classList.add('btn-used');
// Persist used state in cached recipe
try {
const cached = JSON.parse(localStorage.getItem('cachedRecipe') || 'null');
if (cached && cached.recipe && cached.recipe.ingredients && cached.recipe.ingredients[idx]) {
cached.recipe.ingredients[idx].used = true;
localStorage.setItem('cachedRecipe', JSON.stringify(cached));
}
} catch (e) { /* ignore */ }
showToast('📦 Ingrediente scalato dalla dispensa!', 'success');
if (result.added_to_bring) {
setTimeout(() => showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'), 1500);
}
} else {
btn.disabled = false;
btn.textContent = '📦 Usa';
showToast(result.error || 'Errore nello scalare', 'error');
}
} catch (err) {
console.error('Use ingredient error:', err);
btn.disabled = false;
btn.textContent = '📦 Usa';
showToast('Errore di connessione', 'error');
}
}
function renderRecipe(r) {
let html = `<h2>${r.title}</h2>`;
// Meta tags
html += '<div class="recipe-meta">';
html += `<span class="recipe-tag">${MEAL_LABELS[r.meal] || r.meal}</span>`;
html += `<span class="recipe-tag">👥 ${r.persons} pers.</span>`;
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
if (r.tags) r.tags.forEach(t => { html += `<span class="recipe-tag">${t}</span>`; });
html += '</div>';
// Expiry note
if (r.expiry_note) {
html += `<div class="recipe-expiry-note">⚠️ ${r.expiry_note}</div>`;
}
// Ingredients
html += '<h3>🧾 Ingredienti</h3><ul class="recipe-ingredients">';
(r.ingredients || []).forEach((ing, idx) => {
if (ing.from_pantry && ing.product_id) {
const qtyNum = ing.qty_number || 0;
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
const alreadyUsed = ing.used === true;
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}">`;
html += `<span class="recipe-ing-text"><strong>${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: ${ing.qty} ✅`;
// Detail line: location + expiry
let details = [];
const locLabels = { 'frigo': '🧊 Frigo', 'freezer': '🧊 Freezer', 'dispensa': '🗄️ Dispensa' };
details.push(locLabels[ing.location] || ('📍 ' + ing.location));
if (ing.expiry_date) {
const exp = new Date(ing.expiry_date);
const now = new Date(); now.setHours(0,0,0,0);
const diffDays = Math.round((exp - now) / 86400000);
if (diffDays < 0) details.push(`⛔ Scaduto da ${Math.abs(diffDays)}g`);
else if (diffDays <= 3) details.push(`🔴 Scade tra ${diffDays}g`);
else if (diffDays <= 7) details.push(`🟡 Scade tra ${diffDays}g`);
else details.push(`📅 ${exp.toLocaleDateString('it-IT')}`);
}
if (details.length) html += `<br><small class="recipe-ing-detail">${details.join(' · ')}</small>`;
html += `</span>`;
if (alreadyUsed) {
html += `<button class="btn-use-ingredient btn-used" disabled>✔️ Scalato</button>`;
} else {
html += `<button class="btn-use-ingredient" onclick="useRecipeIngredient(${idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this)" title="Scala dalla dispensa">📦 Usa</button>`;
}
html += `</li>`;
} else {
const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒';
html += `<li class="recipe-ingredient"><span class="recipe-ing-text"><strong>${ing.name}</strong>: ${ing.qty}${pantryIcon}</span></li>`;
}
});
html += '</ul>';
// Steps
html += '<h3>👨‍🍳 Procedimento</h3><ol>';
(r.steps || []).forEach(step => {
const cleanStep = step.replace(/^Passo\s*\d+\s*:\s*/i, '');
html += `<li>${cleanStep}</li>`;
});
html += '</ol>';
// Nutrition note
if (r.nutrition_note) {
html += `<p style="color:var(--text-muted);font-size:0.85rem;margin-top:12px">💡 ${r.nutrition_note}</p>`;
}
document.getElementById('recipe-content').innerHTML = html;
}
function regenerateRecipe() {
localStorage.removeItem('cachedRecipe');
document.getElementById('recipe-result').style.display = 'none';
document.getElementById('recipe-loading').style.display = 'none';
const meal = getMealType();
document.getElementById('recipe-meal-title').textContent = MEAL_LABELS[meal] || '🍳 Ricetta';
document.getElementById('recipe-persons').value = 1;
document.getElementById('recipe-ask').style.display = '';
}
async function generateRecipe() {
const meal = getMealType();
const persons = parseInt(document.getElementById('recipe-persons').value) || 1;
const settings = getSettings();
// Gather active options from checkboxes
const options = [];
const optMap = {
'recipe-opt-veloce': 'veloce',
'recipe-opt-pocafame': 'pocafame',
'recipe-opt-scadenze': 'scadenze',
'recipe-opt-healthy': 'salutare',
'recipe-opt-comfort': 'comfort',
'recipe-opt-zerowaste': 'zerowaste'
};
Object.entries(optMap).forEach(([id, key]) => {
const cb = document.getElementById(id);
if (cb && cb.checked) options.push(key);
});
document.getElementById('recipe-ask').style.display = 'none';
document.getElementById('recipe-loading').style.display = '';
document.getElementById('recipe-result').style.display = 'none';
try {
const result = await api('generate_recipe', {}, 'POST', {
meal,
persons,
options,
appliances: settings.appliances || [],
dietary_restrictions: settings.dietary_restrictions || '',
today_recipes: getTodayRecipeTitles()
});
if (!result.success) {
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
if (result.error === 'no_api_key') {
showToast('⚠️ Chiave API Gemini non configurata', 'warning');
} else {
showToast(result.error || 'Errore nella generazione', 'error');
}
return;
}
const r = result.recipe;
renderRecipe(r);
// Save to archive
saveRecipeToArchive(r);
// Cache the recipe for this meal type
localStorage.setItem('cachedRecipe', JSON.stringify({ meal, recipe: r }));
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-result').style.display = '';
} catch (err) {
console.error('Recipe error:', err);
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
showToast('Errore di connessione', 'error');
}
}
// ===== GEMINI CHAT =====
let chatHistory = [];
let chatInventoryContext = null;
function initChat() {
// Load chat history from localStorage
const saved = localStorage.getItem('gemini_chat_history');
if (saved) {
try {
chatHistory = JSON.parse(saved);
renderChatHistory();
} catch(e) { chatHistory = []; }
}
// Pre-load inventory context
loadChatContext();
// Focus input
setTimeout(() => {
const input = document.getElementById('chat-input');
if (input) input.focus();
}, 300);
}
async function loadChatContext() {
try {
const data = await api('inventory_list');
chatInventoryContext = data.inventory || [];
} catch(e) { chatInventoryContext = []; }
}
function sendChatSuggestion(text) {
document.getElementById('chat-input').value = text;
sendChatMessage();
}
async function sendChatMessage() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text) return;
input.value = '';
// Hide welcome if first message
const welcome = document.querySelector('.chat-welcome');
if (welcome) welcome.style.display = 'none';
// Add user message
chatHistory.push({ role: 'user', text });
appendChatBubble('user', text);
saveChatHistory();
// Show typing indicator
const typingEl = appendChatBubble('gemini', '<div class="chat-typing"><span></span><span></span><span></span></div>', true);
scrollChatBottom();
// Disable send
const btn = document.getElementById('btn-chat-send');
btn.disabled = true;
try {
const settings = getSettings();
const result = await api('gemini_chat', {}, 'POST', {
message: text,
history: chatHistory.slice(0, -1).slice(-20), // last 20 messages for context
appliances: settings.appliances || [],
dietary_restrictions: settings.dietary_restrictions || ''
});
// Remove typing indicator
typingEl.remove();
if (result.success) {
chatHistory.push({ role: 'gemini', text: result.reply });
appendChatBubble('gemini', formatChatReply(result.reply));
} else {
const errMsg = result.error === 'no_api_key' ? 'Configura la chiave API Gemini nelle impostazioni.' : (result.error || 'Errore nella risposta');
appendChatBubble('gemini', `⚠️ ${escapeHtml(errMsg)}`);
}
} catch(err) {
typingEl.remove();
appendChatBubble('gemini', '⚠️ Errore di connessione');
}
btn.disabled = false;
saveChatHistory();
scrollChatBottom();
}
function appendChatBubble(role, html, isRaw = false) {
const container = document.getElementById('chat-messages');
const bubble = document.createElement('div');
bubble.className = `chat-bubble chat-${role}`;
if (isRaw) {
bubble.innerHTML = html;
} else if (role === 'user') {
bubble.textContent = html;
} else {
bubble.innerHTML = html;
}
container.appendChild(bubble);
scrollChatBottom();
return bubble;
}
function formatChatReply(text) {
// Convert markdown-like formatting
let html = escapeHtml(text);
// Bold **text**
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic *text*
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Lists
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
// Numbered lists
html = html.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>');
// Line breaks
html = html.replace(/\n/g, '<br>');
// Clean up consecutive ul tags
html = html.replace(/<\/ul>\s*<br>\s*<ul>/g, '');
return html;
}
function renderChatHistory() {
const container = document.getElementById('chat-messages');
if (chatHistory.length === 0) return;
// Hide welcome
const welcome = container.querySelector('.chat-welcome');
if (welcome) welcome.style.display = 'none';
chatHistory.forEach(msg => {
if (msg.role === 'user') {
appendChatBubble('user', msg.text);
} else {
appendChatBubble('gemini', formatChatReply(msg.text));
}
});
scrollChatBottom();
}
function scrollChatBottom() {
const container = document.getElementById('chat-messages');
setTimeout(() => container.scrollTop = container.scrollHeight, 50);
}
function clearChat() {
chatHistory = [];
localStorage.removeItem('gemini_chat_history');
const container = document.getElementById('chat-messages');
container.innerHTML = `
<div class="chat-welcome">
<svg class="gemini-icon-lg" viewBox="0 0 24 24" width="48" height="48" fill="#6366f1"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
<h3>Ciao! Sono il tuo assistente cucina</h3>
<p>Chiedimi di prepararti un succo, uno spuntino, un piatto veloce... Conosco la tua dispensa, i tuoi elettrodomestici e le tue preferenze!</p>
<div class="chat-suggestions">
<button class="chat-suggestion" onclick="sendChatSuggestion('Cosa posso preparare per uno spuntino veloce?')">🍿 Spuntino veloce</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Fammi un succo o frullato con quello che ho')">🥤 Succo/Frullato</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Ho fame ma voglio qualcosa di leggero')">🥗 Qualcosa di leggero</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Cosa sta per scadere e come posso usarlo?')">⏰ Usa le scadenze</button>
</div>
</div>
`;
showToast('Chat cancellata', 'success');
}
function saveChatHistory() {
// Keep last 50 messages max
if (chatHistory.length > 50) chatHistory = chatHistory.slice(-50);
localStorage.setItem('gemini_chat_history', JSON.stringify(chatHistory));
}
// ===== INITIALIZATION =====
document.addEventListener('DOMContentLoaded', () => {
showPage('dashboard');
});
// ===== DUPLICLICK (SPESA ONLINE) =====
function selectSpesaProvider(btn, provider) {
document.querySelectorAll('.provider-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const s = getSettings();
s.spesa_provider = provider;
saveSettingsToStorage(s);
}
async function spesaLogin() {
const email = document.getElementById('setting-spesa-email').value.trim();
const password = document.getElementById('setting-spesa-password').value.trim();
const s = getSettings();
const provider = s.spesa_provider || 'dupliclick';
if (!email || !password) {
showToast('Inserisci email e password', 'error');
return;
}
const btn = document.getElementById('spesa-login-btn');
const statusEl = document.getElementById('spesa-login-status');
const resultEl = document.getElementById('spesa-login-result');
btn.disabled = true;
btn.innerHTML = '⏳ Accesso in corso...';
statusEl.style.display = 'none';
resultEl.style.display = 'none';
try {
const res = await api(`${provider}_login`, {}, 'POST', { email, password });
if (res.error) {
statusEl.className = 'dupliclick-status error';
statusEl.innerHTML = `❌ <strong>Errore:</strong> ${escapeHtml(res.error)}`;
statusEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '🔐 Accedi';
return;
}
// Save credentials and session data persistently
s.spesa_email = email;
s.spesa_password = password;
s.spesa_token = res.token_full || '';
s.spesa_provider = provider;
s.spesa_logged_in = true;
s.spesa_user = res.user || (res.data && res.data.user) || {};
s.spesa_data = res.data || {};
// Save AI prompt too
const promptEl = document.getElementById('setting-spesa-ai-prompt');
if (promptEl) s.spesa_ai_prompt = promptEl.value.trim();
saveSettingsToStorage(s);
statusEl.className = 'dupliclick-status success';
const welcomeMsg = (res.infos && res.infos[0]) ? res.infos[0].info : 'Login effettuato!';
statusEl.innerHTML = `✅ <strong>${escapeHtml(welcomeMsg)}</strong>`;
statusEl.style.display = 'block';
// Display key info only
const user = res.user || (res.data && res.data.user) || {};
const data = res.data || {};
const shipping = data.shippingAddress || {};
const points = user.userPoints || data.userPoints || {};
const fidelityPts = Array.isArray(points) ? points[0] : points['0'];
let html = '<div class="dupliclick-data">';
html += '<div class="dupliclick-data-grid">';
if (user.firstName) html += `<div class="data-row"><span class="data-label">👤 Nome</span><span class="data-value">${escapeHtml(user.firstName)} ${escapeHtml(user.lastName || '')}</span></div>`;
if (user.fidelityCard) html += `<div class="data-row"><span class="data-label">💳 Tessera</span><span class="data-value">${escapeHtml(user.fidelityCard)}</span></div>`;
if (shipping.addressName) html += `<div class="data-row"><span class="data-label">🏪 Punto Ritiro</span><span class="data-value">${escapeHtml(shipping.addressName)}</span></div>`;
if (fidelityPts) html += `<div class="data-row"><span class="data-label">⭐ Punti Fedeltà</span><span class="data-value">${fidelityPts.value || 0}</span></div>`;
html += '</div></div>';
resultEl.innerHTML = html;
resultEl.style.display = 'block';
} catch (e) {
statusEl.className = 'dupliclick-status error';
statusEl.innerHTML = `❌ <strong>Errore di rete:</strong> ${escapeHtml(e.message)}`;
statusEl.style.display = 'block';
}
btn.disabled = false;
btn.innerHTML = '🔐 Accedi';
}
function loadSpesaSettings() {
const s = getSettings();
const emailEl = document.getElementById('setting-spesa-email');
const passEl = document.getElementById('setting-spesa-password');
const promptEl = document.getElementById('setting-spesa-ai-prompt');
if (emailEl) emailEl.value = s.spesa_email || s.dupliclick_email || '';
if (passEl) passEl.value = s.spesa_password || s.dupliclick_password || '';
if (promptEl) promptEl.value = s.spesa_ai_prompt || DEFAULT_SPESA_AI_PROMPT;
// Show saved login state
if (s.spesa_logged_in && s.spesa_user) {
const statusEl = document.getElementById('spesa-login-status');
const resultEl = document.getElementById('spesa-login-result');
const loginBtn = document.getElementById('spesa-login-btn');
if (loginBtn) {
loginBtn.innerHTML = '✅ Connesso — Riaccedi';
loginBtn.className = 'btn btn-large btn-secondary full-width mt-2';
}
if (statusEl) {
statusEl.className = 'dupliclick-status success';
statusEl.innerHTML = `✅ <strong>Connesso come ${escapeHtml(s.spesa_user.firstName || '')} ${escapeHtml(s.spesa_user.lastName || '')}</strong>`;
statusEl.style.display = 'block';
}
if (resultEl) {
const user = s.spesa_user;
const shipping = (s.spesa_data && s.spesa_data.shippingAddress) || {};
const points = user.userPoints || (s.spesa_data && s.spesa_data.userPoints) || {};
const fidelityPts = Array.isArray(points) ? points[0] : points['0'];
let html = '<div class="dupliclick-data"><div class="dupliclick-data-grid">';
if (user.firstName) html += `<div class="data-row"><span class="data-label">👤 Nome</span><span class="data-value">${escapeHtml(user.firstName)} ${escapeHtml(user.lastName || '')}</span></div>`;
if (user.fidelityCard) html += `<div class="data-row"><span class="data-label">💳 Tessera</span><span class="data-value">${escapeHtml(user.fidelityCard)}</span></div>`;
if (shipping.addressName) html += `<div class="data-row"><span class="data-label">🏪 Punto Ritiro</span><span class="data-value">${escapeHtml(shipping.addressName)}</span></div>`;
if (fidelityPts) html += `<div class="data-row"><span class="data-label">⭐ Punti Fedeltà</span><span class="data-value">${fidelityPts.value || 0}</span></div>`;
html += '</div></div>';
resultEl.innerHTML = html;
resultEl.style.display = 'block';
}
}
}