commit 70ee253a07b2e5136718b16fd774f2a70268553f Author: dadaloop82 Date: Tue Mar 10 10:52:18 2026 +0000 Initial commit: Dispensa - home pantry management app Features: - Barcode scanning (QuaggaJS) + Open Food Facts API lookup - Inventory management with locations (Frigo, Freezer, Dispensa) - Product database with Italian product suggestions - Expiry date estimation by category - AI expiry date reading via Gemini Vision API - Flexible unit of measure (pz, conf, g, kg, ml, L) - Nutriscore/NOVA/Ecoscore/allergens display - Mobile-first PWA with offline support - SQLite backend with PHP REST API diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0d34fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Environment variables (secrets) +.env + +# SQLite database +data/ + +# SSL CA cert (local only) +ca.crt + +# OS files +.DS_Store +Thumbs.db + +# Editor +*.swp +*.swo +*~ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..67a8114 --- /dev/null +++ b/.htaccess @@ -0,0 +1,11 @@ +RewriteEngine On + +# Force HTTPS +RewriteCond %{HTTPS} !=on +RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# API routing +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^api/(.*)$ api/index.php?action=$1&%{QUERY_STRING} [L,QSA] +AddType application/x-x509-ca-cert .crt diff --git a/api/database.php b/api/database.php new file mode 100644 index 0000000..12e0eef --- /dev/null +++ b/api/database.php @@ -0,0 +1,66 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $db->exec("PRAGMA journal_mode=WAL"); + $db->exec("PRAGMA foreign_keys=ON"); + + if ($isNew) { + initializeDB($db); + } + return $db; +} + +function initializeDB(PDO $db): void { + $db->exec(" + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + barcode TEXT UNIQUE, + name TEXT NOT NULL, + brand TEXT DEFAULT '', + category TEXT DEFAULT '', + image_url TEXT DEFAULT '', + unit TEXT DEFAULT 'pz', + default_quantity REAL DEFAULT 1, + notes TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL, + location TEXT NOT NULL DEFAULT 'dispensa', + quantity REAL NOT NULL DEFAULT 1, + expiry_date DATE, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('in', 'out')), + quantity REAL NOT NULL, + location TEXT NOT NULL DEFAULT 'dispensa', + notes TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_products_barcode ON products(barcode); + CREATE INDEX IF NOT EXISTS idx_inventory_product ON inventory(product_id); + CREATE INDEX IF NOT EXISTS idx_inventory_location ON inventory(location); + CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id); + CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at); + "); +} diff --git a/api/index.php b/api/index.php new file mode 100644 index 0000000..a239ce4 --- /dev/null +++ b/api/index.php @@ -0,0 +1,617 @@ + 'Database connection failed: ' . $e->getMessage()]); + exit; +} + +$method = $_SERVER['REQUEST_METHOD']; +$action = $_GET['action'] ?? ''; + +try { + switch ($action) { + // ===== PRODUCTS ===== + case 'search_barcode': + searchBarcode($db); + break; + case 'lookup_barcode': + lookupBarcode(); + break; + case 'product_save': + saveProduct($db); + break; + case 'product_get': + getProduct($db); + break; + case 'product_delete': + deleteProduct($db); + break; + case 'products_list': + listProducts($db); + break; + case 'products_search': + searchProducts($db); + break; + + // ===== INVENTORY ===== + case 'inventory_list': + listInventory($db); + break; + case 'inventory_add': + addToInventory($db); + break; + case 'inventory_use': + useFromInventory($db); + break; + case 'inventory_update': + updateInventory($db); + break; + case 'inventory_delete': + deleteInventory($db); + break; + case 'inventory_summary': + inventorySummary($db); + break; + + // ===== TRANSACTIONS ===== + case 'transactions_list': + listTransactions($db); + break; + + // ===== STATS ===== + case 'stats': + getStats($db); + break; + + // ===== AI ===== + case 'gemini_expiry': + geminiReadExpiry(); + break; + + default: + http_response_code(404); + echo json_encode(['error' => 'Unknown action: ' . $action]); + } +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} + +// ===== PRODUCT FUNCTIONS ===== + +function searchBarcode(PDO $db): void { + $barcode = $_GET['barcode'] ?? ''; + if (empty($barcode)) { + echo json_encode(['found' => false]); + return; + } + $stmt = $db->prepare("SELECT * FROM products WHERE barcode = ?"); + $stmt->execute([$barcode]); + $product = $stmt->fetch(); + if ($product) { + echo json_encode(['found' => true, 'product' => $product]); + } else { + echo json_encode(['found' => false]); + } +} + +function lookupBarcode(): void { + $barcode = $_GET['barcode'] ?? ''; + if (empty($barcode)) { + echo json_encode(['found' => false, 'error' => 'No barcode provided']); + return; + } + + // Try Open Food Facts API (Italian version first for better localized data) + $url = "https://world.openfoodfacts.org/api/v2/product/{$barcode}.json?fields=product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores&lc=it"; + $ctx = stream_context_create([ + 'http' => [ + 'timeout' => 10, + 'header' => "User-Agent: DispensaManager/1.0\r\n" + ] + ]); + + $response = @file_get_contents($url, false, $ctx); + if ($response === false) { + echo json_encode(['found' => false, 'source' => 'openfoodfacts', 'error' => 'API request failed']); + return; + } + + $data = json_decode($response, true); + if (isset($data['status']) && $data['status'] === 1 && !empty($data['product'])) { + $p = $data['product']; + + // Prefer Italian name, fall back to generic + $name = ''; + if (!empty($p['product_name_it'])) { + $name = $p['product_name_it']; + } elseif (!empty($p['product_name'])) { + $name = $p['product_name']; + } elseif (!empty($p['generic_name_it'])) { + $name = $p['generic_name_it']; + } elseif (!empty($p['generic_name'])) { + $name = $p['generic_name']; + } + + // Get Italian ingredients, fall back to generic + $ingredients = ''; + if (!empty($p['ingredients_text_it'])) { + $ingredients = $p['ingredients_text_it']; + } elseif (!empty($p['ingredients_text'])) { + $ingredients = $p['ingredients_text']; + } + + // Category: prefer Italian categories_tags, fallback + $category = ''; + if (!empty($p['categories_tags'])) { + // Try to find an Italian-friendly category + $category = $p['categories_tags'][0] ?? ''; + } elseif (!empty($p['categories_hierarchy'])) { + $category = end($p['categories_hierarchy']); + } elseif (!empty($p['categories'])) { + $category = $p['categories']; + } + + // Allergens + $allergens = ''; + if (!empty($p['allergens_tags'])) { + $allergens = implode(', ', array_map(function($a) { + return str_replace('en:', '', $a); + }, $p['allergens_tags'])); + } + + // Conservation / storage + $conservation = $p['conservation_conditions_it'] ?? $p['conservation_conditions'] ?? ''; + + // Origin + $origin = $p['origins_it'] ?? $p['origins'] ?? $p['manufacturing_places'] ?? ''; + + $result = [ + 'found' => true, + 'source' => 'openfoodfacts', + 'product' => [ + 'name' => $name, + 'brand' => $p['brands'] ?? '', + 'category' => $category, + 'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '', + 'quantity_info' => $p['quantity'] ?? '', + 'nutriscore' => $p['nutriscore_grade'] ?? '', + 'ingredients' => $ingredients, + 'allergens' => $allergens, + 'conservation' => $conservation, + 'origin' => $origin, + 'nova_group' => $p['nova_group'] ?? '', + 'ecoscore' => $p['ecoscore_grade'] ?? '', + 'labels' => $p['labels'] ?? '', + 'stores' => $p['stores'] ?? '', + ] + ]; + echo json_encode($result); + } else { + // Try UPC Item DB as fallback + $url2 = "https://api.upcitemdb.com/prod/trial/lookup?upc={$barcode}"; + $ctx2 = stream_context_create([ + 'http' => [ + 'timeout' => 10, + 'header' => "User-Agent: DispensaManager/1.0\r\n" + ] + ]); + $response2 = @file_get_contents($url2, false, $ctx2); + if ($response2 !== false) { + $data2 = json_decode($response2, true); + if (!empty($data2['items'][0])) { + $item = $data2['items'][0]; + echo json_encode([ + 'found' => true, + 'source' => 'upcitemdb', + 'product' => [ + 'name' => $item['title'] ?? '', + 'brand' => $item['brand'] ?? '', + 'category' => $item['category'] ?? '', + 'image_url' => $item['images'][0] ?? '', + ] + ]); + return; + } + } + echo json_encode(['found' => false, 'source' => 'openfoodfacts']); + } +} + +function saveProduct(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + if (!$input || empty($input['name'])) { + http_response_code(400); + echo json_encode(['error' => 'Product name is required']); + return; + } + + if (!empty($input['id'])) { + // Update existing + $stmt = $db->prepare(" + UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?, + default_quantity=?, notes=?, barcode=?, updated_at=CURRENT_TIMESTAMP + WHERE id=? + "); + $stmt->execute([ + $input['name'], $input['brand'] ?? '', $input['category'] ?? '', + $input['image_url'] ?? '', $input['unit'] ?? 'pz', + $input['default_quantity'] ?? 1, $input['notes'] ?? '', + $input['barcode'] ?? null, $input['id'] + ]); + echo json_encode(['success' => true, 'id' => $input['id']]); + } else { + // Insert new + $stmt = $db->prepare(" + INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "); + $barcode = !empty($input['barcode']) ? $input['barcode'] : null; + $stmt->execute([ + $barcode, $input['name'], $input['brand'] ?? '', + $input['category'] ?? '', $input['image_url'] ?? '', + $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, + $input['notes'] ?? '' + ]); + echo json_encode(['success' => true, 'id' => $db->lastInsertId()]); + } +} + +function getProduct(PDO $db): void { + $id = $_GET['id'] ?? 0; + $stmt = $db->prepare("SELECT * FROM products WHERE id = ?"); + $stmt->execute([$id]); + $product = $stmt->fetch(); + if ($product) { + echo json_encode(['success' => true, 'product' => $product]); + } else { + http_response_code(404); + echo json_encode(['error' => 'Product not found']); + } +} + +function deleteProduct(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? 0; + $stmt = $db->prepare("DELETE FROM products WHERE id = ?"); + $stmt->execute([$id]); + echo json_encode(['success' => true]); +} + +function listProducts(PDO $db): void { + $stmt = $db->query("SELECT * FROM products ORDER BY name ASC"); + echo json_encode(['products' => $stmt->fetchAll()]); +} + +function searchProducts(PDO $db): void { + $q = $_GET['q'] ?? ''; + $stmt = $db->prepare("SELECT * FROM products WHERE name LIKE ? OR brand LIKE ? OR barcode LIKE ? ORDER BY name ASC LIMIT 20"); + $like = "%{$q}%"; + $stmt->execute([$like, $like, $like]); + echo json_encode(['products' => $stmt->fetchAll()]); +} + +// ===== INVENTORY FUNCTIONS ===== + +function listInventory(PDO $db): void { + $location = $_GET['location'] ?? ''; + $query = " + SELECT i.*, p.name, p.brand, p.category, p.image_url, p.unit, p.barcode + FROM inventory i + JOIN products p ON i.product_id = p.id + "; + $params = []; + if (!empty($location)) { + $query .= " WHERE i.location = ?"; + $params[] = $location; + } + $query .= " ORDER BY p.name ASC"; + $stmt = $db->prepare($query); + $stmt->execute($params); + echo json_encode(['inventory' => $stmt->fetchAll()]); +} + +function addToInventory(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $productId = $input['product_id'] ?? 0; + $quantity = $input['quantity'] ?? 1; + $location = $input['location'] ?? 'dispensa'; + $expiry = $input['expiry_date'] ?? null; + $unit = $input['unit'] ?? null; + + if (!$productId) { + http_response_code(400); + echo json_encode(['error' => 'Product ID required']); + return; + } + + // If a different unit was specified, update the product's unit + if ($unit) { + $stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$unit, $quantity, $productId]); + } + + // Check if product already exists in this location + $stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ?"); + $stmt->execute([$productId, $location]); + $existing = $stmt->fetch(); + + if ($existing) { + // Update quantity + $newQty = $existing['quantity'] + $quantity; + $stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$newQty, $expiry, $existing['id']]); + } else { + // Insert new inventory entry + $stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date) VALUES (?, ?, ?, ?)"); + $stmt->execute([$productId, $location, $quantity, $expiry]); + } + + // Log transaction + $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location) VALUES (?, 'in', ?, ?)"); + $stmt->execute([$productId, $quantity, $location]); + + echo json_encode(['success' => true]); +} + +function useFromInventory(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $productId = $input['product_id'] ?? 0; + $quantity = $input['quantity'] ?? 0; + $useAll = $input['use_all'] ?? false; + $location = $input['location'] ?? 'dispensa'; + + if (!$productId) { + http_response_code(400); + echo json_encode(['error' => 'Product ID required']); + return; + } + + $stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0"); + $stmt->execute([$productId, $location]); + $existing = $stmt->fetch(); + + if (!$existing) { + http_response_code(404); + echo json_encode(['error' => 'Product not found in inventory at this location']); + return; + } + + if ($useAll) { + $quantity = $existing['quantity']; + } + + $newQty = max(0, $existing['quantity'] - $quantity); + + if ($newQty <= 0) { + $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); + $stmt->execute([$existing['id']]); + } else { + $stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$newQty, $existing['id']]); + } + + // Log transaction + $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location) VALUES (?, 'out', ?, ?)"); + $stmt->execute([$productId, $quantity, $location]); + + echo json_encode(['success' => true, 'remaining' => $newQty]); +} + +function updateInventory(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? 0; + + $fields = []; + $params = []; + if (isset($input['quantity'])) { $fields[] = "quantity = ?"; $params[] = $input['quantity']; } + if (isset($input['location'])) { $fields[] = "location = ?"; $params[] = $input['location']; } + if (isset($input['expiry_date'])) { $fields[] = "expiry_date = ?"; $params[] = $input['expiry_date']; } + $fields[] = "updated_at = CURRENT_TIMESTAMP"; + $params[] = $id; + + $stmt = $db->prepare("UPDATE inventory SET " . implode(', ', $fields) . " WHERE id = ?"); + $stmt->execute($params); + echo json_encode(['success' => true]); +} + +function deleteInventory(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? 0; + $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); + $stmt->execute([$id]); + echo json_encode(['success' => true]); +} + +function inventorySummary(PDO $db): void { + $stmt = $db->query(" + SELECT i.location, COUNT(DISTINCT i.product_id) as product_count, + SUM(i.quantity) as total_items + FROM inventory i + GROUP BY i.location + "); + echo json_encode(['summary' => $stmt->fetchAll()]); +} + +// ===== TRANSACTION FUNCTIONS ===== + +function listTransactions(PDO $db): void { + $limit = $_GET['limit'] ?? 50; + $productId = $_GET['product_id'] ?? ''; + + $query = " + SELECT t.*, p.name, p.brand, p.unit + FROM transactions t + JOIN products p ON t.product_id = p.id + "; + $params = []; + if (!empty($productId)) { + $query .= " WHERE t.product_id = ?"; + $params[] = $productId; + } + $query .= " ORDER BY t.created_at DESC LIMIT ?"; + $params[] = (int)$limit; + + $stmt = $db->prepare($query); + $stmt->execute($params); + echo json_encode(['transactions' => $stmt->fetchAll()]); +} + +// ===== STATS ===== + +function getStats(PDO $db): void { + $totalProducts = $db->query("SELECT COUNT(*) FROM products")->fetchColumn(); + $totalItems = $db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory")->fetchColumn(); + $locations = $db->query("SELECT COUNT(DISTINCT location) FROM inventory")->fetchColumn(); + $recentIn = $db->query("SELECT COUNT(*) FROM transactions WHERE type='in' AND created_at >= datetime('now', '-7 days')")->fetchColumn(); + $recentOut = $db->query("SELECT COUNT(*) FROM transactions WHERE type='out' AND created_at >= datetime('now', '-7 days')")->fetchColumn(); + + // Expiring soon (next 7 days) + $expiring = $db->query(" + SELECT i.*, p.name, p.brand + FROM inventory i JOIN products p ON i.product_id = p.id + WHERE i.expiry_date IS NOT NULL AND i.expiry_date <= date('now', '+7 days') AND i.expiry_date >= date('now') + ORDER BY i.expiry_date ASC + ")->fetchAll(); + + // Expired + $expired = $db->query(" + SELECT i.*, p.name, p.brand + FROM inventory i JOIN products p ON i.product_id = p.id + WHERE i.expiry_date IS NOT NULL AND i.expiry_date < date('now') + ORDER BY i.expiry_date ASC + ")->fetchAll(); + + echo json_encode([ + 'total_products' => (int)$totalProducts, + 'total_items' => (float)$totalItems, + 'locations' => (int)$locations, + 'recent_in' => (int)$recentIn, + 'recent_out' => (int)$recentOut, + 'expiring_soon' => $expiring, + 'expired' => $expired, + ]); +} + +// ===== GEMINI AI FUNCTIONS ===== + +function geminiReadExpiry(): void { + // Load API key from .env + $envFile = __DIR__ . '/../.env'; + $apiKey = ''; + if (file_exists($envFile)) { + $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + if (strpos($line, '#') === 0) continue; + if (strpos($line, '=') !== false) { + list($key, $val) = explode('=', $line, 2); + if (trim($key) === 'GEMINI_API_KEY') { + $apiKey = trim($val); + } + } + } + } + + if (empty($apiKey)) { + echo json_encode(['success' => false, 'error' => 'no_api_key']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + $imageBase64 = $input['image'] ?? ''; + + if (empty($imageBase64)) { + echo json_encode(['success' => false, 'error' => 'No image provided']); + return; + } + + // Call Gemini API + $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; + + $payload = [ + 'contents' => [ + [ + 'parts' => [ + [ + 'text' => "Analizza questa immagine di un prodotto alimentare. Cerca la data di scadenza (\"da consumarsi entro\", \"da consumarsi preferibilmente entro\", \"scad.\", \"exp\", \"best before\", \"TMC\", o date stampate).\n\nRispondi SOLO con un JSON nel formato: {\"found\": true, \"date\": \"YYYY-MM-DD\", \"raw_text\": \"testo letto\"}\nSe non trovi una data: {\"found\": false, \"raw_text\": \"testo letto se presente\"}\n\nSe la data ha solo mese e anno (es. 03/2027), usa il primo giorno del mese. Se ha solo giorno e mese (es. 15/04), assumi l'anno corrente o il prossimo se la data Γ¨ giΓ  passata." + ], + [ + 'inline_data' => [ + 'mime_type' => 'image/jpeg', + 'data' => $imageBase64 + ] + ] + ] + ] + ], + 'generationConfig' => [ + 'temperature' => 0.1, + 'maxOutputTokens' => 256 + ] + ]; + + $jsonPayload = json_encode($payload); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $jsonPayload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($response === false || $httpCode !== 200) { + echo json_encode(['success' => false, 'error' => 'Gemini API error', 'http_code' => $httpCode]); + return; + } + + $data = json_decode($response, true); + $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; + + // Parse the JSON response from Gemini + // Remove potential markdown code block wrapping + $text = preg_replace('/^```json\\s*/i', '', $text); + $text = preg_replace('/\\s*```$/i', '', $text); + $text = trim($text); + + $parsed = json_decode($text, true); + + if ($parsed && !empty($parsed['found']) && !empty($parsed['date'])) { + // Validate date format + $date = $parsed['date']; + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + echo json_encode(['success' => true, 'expiry_date' => $date, 'raw_text' => $parsed['raw_text'] ?? '']); + return; + } + } + + echo json_encode([ + 'success' => false, + 'error' => 'Could not parse expiry date', + 'raw_text' => $parsed['raw_text'] ?? $text + ]); +} diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..99a5062 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,1444 @@ +/* ===== CSS VARIABLES & RESET ===== */ +:root { + --primary: #2d5016; + --primary-light: #4a7c28; + --primary-dark: #1a3009; + --success: #16a34a; + --success-light: #22c55e; + --danger: #dc2626; + --danger-light: #ef4444; + --warning: #f59e0b; + --warning-light: #fbbf24; + --accent: #7c3aed; + --accent-light: #8b5cf6; + --bg: #f0f4e8; + --bg-card: #ffffff; + --bg-dark: #1a1a2e; + --text: #1a1a1a; + --text-light: #6b7280; + --text-muted: #9ca3af; + --border: #e5e7eb; + --shadow: 0 2px 8px rgba(0,0,0,0.1); + --shadow-lg: 0 4px 16px rgba(0,0,0,0.15); + --radius: 16px; + --radius-sm: 10px; + --nav-height: 70px; + --header-height: 56px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + overflow-x: hidden; + padding-top: var(--header-height); + padding-bottom: var(--nav-height); + -webkit-font-smoothing: antialiased; +} + +/* ===== HEADER ===== */ +.app-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--header-height); + background: linear-gradient(135deg, var(--primary), var(--primary-light)); + color: white; + z-index: 100; + display: flex; + align-items: center; + padding: 0 16px; + box-shadow: var(--shadow); +} + +.header-content { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + max-width: 600px; + margin: 0 auto; +} + +.header-title { + font-size: 1.3rem; + font-weight: 700; + cursor: pointer; +} + +.header-btn { + background: rgba(255,255,255,0.2); + border: none; + color: white; + font-size: 1.3rem; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.header-btn:active { + background: rgba(255,255,255,0.4); +} + +/* ===== MAIN CONTENT ===== */ +.app-content { + max-width: 600px; + margin: 0 auto; + padding: 12px; + min-height: calc(100vh - var(--header-height) - var(--nav-height)); +} + +/* ===== PAGE MANAGEMENT ===== */ +.page { + display: none; + animation: fadeIn 0.2s ease; +} + +.page.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ===== PAGE HEADER ===== */ +.page-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 2px solid var(--border); +} + +.page-header h2 { + font-size: 1.2rem; + font-weight: 700; +} + +.back-btn { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 14px; + font-size: 0.95rem; + color: var(--primary); + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: all 0.2s; +} + +.back-btn:active { + background: var(--border); +} + +/* ===== DASHBOARD ===== */ +.dashboard-stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 16px; +} + +.stat-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 16px; + text-align: center; + box-shadow: var(--shadow); + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + display: flex; + flex-direction: column; + gap: 4px; +} + +.stat-card:active { + transform: scale(0.97); + box-shadow: var(--shadow-lg); +} + +.stat-icon { + font-size: 2rem; +} + +.stat-value { + font-size: 2rem; + font-weight: 800; + color: var(--primary); +} + +.stat-label { + font-size: 0.85rem; + color: var(--text-light); + font-weight: 600; +} + +/* ===== ALERTS ===== */ +.alert-section { + background: #fef3c7; + border: 2px solid var(--warning); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 12px; +} + +.alert-section h3 { + font-size: 1rem; + margin-bottom: 8px; +} + +.alert-danger { + background: #fee2e2; + border-color: var(--danger); +} + +.alert-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid rgba(0,0,0,0.1); + font-size: 0.9rem; +} + +.alert-item:last-child { + border-bottom: none; +} + +/* ===== SECTION CARD ===== */ +.section-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow); + margin-bottom: 12px; +} + +.section-card h3 { + font-size: 1rem; + margin-bottom: 12px; + color: var(--primary); +} + +/* ===== LOCATION TABS ===== */ +.location-tabs { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 12px; + margin-bottom: 8px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} + +.location-tabs::-webkit-scrollbar { display: none; } + +.tab { + background: var(--bg-card); + border: 2px solid var(--border); + border-radius: 25px; + padding: 8px 16px; + font-size: 0.85rem; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: all 0.2s; +} + +.tab.active { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +.tab:active { + transform: scale(0.95); +} + +/* ===== SEARCH BAR ===== */ +.search-bar { + margin-bottom: 12px; +} + +.search-bar input { + width: 100%; + padding: 14px 16px; + border-radius: var(--radius); + border: 2px solid var(--border); + background: var(--bg-card); + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} + +.search-bar input:focus { + border-color: var(--primary); +} + +/* ===== INVENTORY LIST ===== */ +.inventory-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.inventory-item { + background: var(--bg-card); + border-radius: var(--radius); + padding: 14px; + box-shadow: var(--shadow); + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + transition: transform 0.15s; +} + +.inventory-item:active { + transform: scale(0.98); +} + +.inv-image { + width: 50px; + height: 50px; + border-radius: var(--radius-sm); + object-fit: cover; + background: var(--bg); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; +} + +.inv-image img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: var(--radius-sm); +} + +.inv-info { + flex: 1; + min-width: 0; +} + +.inv-name { + font-weight: 700; + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.inv-brand { + font-size: 0.8rem; + color: var(--text-light); +} + +.inv-meta { + display: flex; + gap: 8px; + margin-top: 4px; + flex-wrap: wrap; +} + +.inv-badge { + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 12px; + font-weight: 600; +} + +.badge-location { + background: #dbeafe; + color: #1d4ed8; +} + +.badge-qty { + background: #d1fae5; + color: #047857; +} + +.badge-expiry { + background: #fef3c7; + color: #92400e; +} + +.badge-expired { + background: #fee2e2; + color: #dc2626; +} + +.inv-actions { + display: flex; + flex-direction: column; + gap: 6px; +} + +.inv-action-btn { + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; + padding: 4px; + border-radius: 8px; + transition: background 0.2s; +} + +.inv-action-btn:active { + background: var(--border); +} + +/* ===== BUTTONS ===== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 20px; + border: none; + border-radius: var(--radius); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + -webkit-user-select: none; + user-select: none; +} + +.btn:active { + transform: scale(0.97); +} + +.btn-large { + padding: 16px 24px; + font-size: 1.1rem; + border-radius: var(--radius); +} + +.btn-huge { + padding: 28px 20px; + font-size: 1.2rem; + border-radius: 20px; + flex: 1; + flex-direction: column; + min-height: 120px; +} + +.btn-icon { + font-size: 2.5rem; +} + +.btn-text { + text-align: center; + line-height: 1.3; +} + +.btn-text small { + font-weight: 400; + font-size: 0.8rem; + opacity: 0.85; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:active { + background: var(--primary-dark); +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-success:active { + background: #15803d; +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-danger:active { + background: #b91c1c; +} + +.btn-warning { + background: var(--warning); + color: white; +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text); + border: 2px solid var(--border); +} + +.btn-accent { + background: var(--accent); + color: white; +} + +.btn-accent:active { + background: #6d28d9; +} + +.full-width { + width: 100%; +} + +.mt-2 { + margin-top: 12px; +} + +/* ===== ACTION BUTTONS (Scan page) ===== */ +.action-buttons { + display: flex; + gap: 12px; + margin-top: 16px; +} + +/* ===== FORMS ===== */ +.form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-weight: 600; + font-size: 0.9rem; + color: var(--text); +} + +.form-input { + width: 100%; + padding: 14px 16px; + border: 2px solid var(--border); + border-radius: var(--radius-sm); + font-size: 1rem; + background: var(--bg-card); + outline: none; + transition: border-color 0.2s; + -webkit-appearance: none; + appearance: none; +} + +.form-input:focus { + border-color: var(--primary); +} + +.form-row { + display: flex; + gap: 12px; +} + +.flex-1 { + flex: 1; +} + +/* ===== LOCATION SELECTOR ===== */ +.location-selector { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.loc-btn { + background: var(--bg-card); + border: 2px solid var(--border); + border-radius: var(--radius-sm); + padding: 14px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.loc-btn.active { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +.loc-btn:active { + transform: scale(0.97); +} + +/* ===== QUANTITY CONTROL ===== */ +.qty-control { + display: flex; + align-items: center; + gap: 4px; +} + +.qty-btn { + width: 50px; + height: 50px; + border: 2px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-card); + font-size: 1.5rem; + font-weight: 700; + color: var(--primary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.qty-btn:active { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +.qty-input { + width: 80px; + height: 50px; + text-align: center; + font-size: 1.3rem; + font-weight: 700; + border: 2px solid var(--border); + border-radius: var(--radius-sm); + outline: none; + -webkit-appearance: none; + -moz-appearance: textfield; +} + +.qty-input:focus { + border-color: var(--primary); +} + +/* ===== USE OPTIONS ===== */ +.use-options { + display: flex; + flex-direction: column; + gap: 16px; +} + +.use-all-btn { + min-height: 70px; + font-size: 1.15rem; +} + +.use-partial { + background: var(--bg); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.use-partial p { + font-size: 0.9rem; + color: var(--text-light); +} + +/* ===== SCANNER ===== */ +.scan-container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.scanner-viewport { + position: relative; + width: 100%; + aspect-ratio: 4/3; + background: #000; + border-radius: var(--radius); + overflow: hidden; +} + +.scanner-viewport video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.scanner-overlay { + position: absolute; + top: 50%; + left: 10%; + right: 10%; + height: 3px; + transform: translateY(-50%); + z-index: 10; + pointer-events: none; +} + +.scanner-line { + width: 100%; + height: 3px; + background: var(--danger); + box-shadow: 0 0 10px var(--danger); + animation: scanLine 2s ease-in-out infinite; +} + +@keyframes scanLine { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.scan-result { + background: var(--bg-card); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow); +} + +.scan-actions { + display: flex; + flex-direction: column; + gap: 10px; +} + +.scan-hint { + text-align: center; + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 4px; +} + +/* ===== PRODUCT PREVIEW ===== */ +.product-preview, .product-preview-small { + background: var(--bg-card); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow); + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 14px; +} + +.product-preview-small { + padding: 12px; +} + +.product-preview img, .product-preview-small img { + width: 60px; + height: 60px; + border-radius: var(--radius-sm); + object-fit: cover; + background: var(--bg); +} + +.product-preview-small img { + width: 45px; + height: 45px; +} + +.product-preview-emoji { + font-size: 2.5rem; + width: 60px; + text-align: center; +} + +.product-preview-info h3 { + font-size: 1.1rem; + font-weight: 700; +} + +.product-preview-info p { + font-size: 0.85rem; + color: var(--text-light); +} + +.product-image-preview { + text-align: center; + margin: 8px 0; +} + +.product-image-preview img { + max-width: 120px; + border-radius: var(--radius-sm); +} + +/* ===== AI PAGE ===== */ +.ai-container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.ai-capture { + position: relative; + width: 100%; + aspect-ratio: 4/3; + background: #000; + border-radius: var(--radius); + overflow: hidden; +} + +.ai-capture video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.ai-preview { + text-align: center; +} + +.ai-preview img { + max-width: 100%; + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.ai-actions { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ai-result { + background: var(--bg-card); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow); +} + +/* ===== BOTTOM NAV ===== */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: var(--nav-height); + background: var(--bg-card); + display: flex; + justify-content: space-around; + align-items: center; + box-shadow: 0 -2px 10px rgba(0,0,0,0.1); + z-index: 100; + padding-bottom: env(safe-area-inset-bottom, 0); +} + +.nav-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + background: none; + border: none; + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; + padding: 6px 10px; + border-radius: var(--radius-sm); + transition: all 0.2s; + min-width: 56px; +} + +.nav-btn.active { + color: var(--primary); +} + +.nav-icon { + font-size: 1.4rem; +} + +.nav-label { + font-size: 0.65rem; +} + +.scan-btn { + position: relative; + margin-top: -28px; +} + +.nav-icon-large { + width: 56px; + height: 56px; + background: linear-gradient(135deg, var(--primary), var(--primary-light)); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.6rem; + box-shadow: 0 4px 12px rgba(45, 80, 22, 0.4); + transition: transform 0.2s; +} + +.scan-btn:active .nav-icon-large { + transform: scale(0.92); +} + +.scan-btn .nav-label { + margin-top: 4px; + color: var(--primary); + font-weight: 700; +} + +/* ===== TOAST ===== */ +.toast { + position: fixed; + bottom: calc(var(--nav-height) + 16px); + left: 50%; + transform: translateX(-50%) translateY(100px); + background: var(--bg-dark); + color: white; + padding: 14px 24px; + border-radius: var(--radius); + font-size: 0.95rem; + font-weight: 600; + z-index: 1000; + opacity: 0; + transition: all 0.3s; + max-width: 90vw; + text-align: center; + box-shadow: var(--shadow-lg); +} + +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.toast.success { + background: var(--success); +} + +.toast.error { + background: var(--danger); +} + +/* ===== LOADING ===== */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 1000; + color: white; + gap: 12px; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 4px solid rgba(255,255,255,0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ===== MODAL ===== */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 200; + padding: 0; +} + +.modal-content { + background: var(--bg-card); + border-radius: var(--radius) var(--radius) 0 0; + width: 100%; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; + padding: 20px; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.modal-header h3 { + font-size: 1.15rem; +} + +.modal-close { + background: var(--bg); + border: none; + font-size: 1.3rem; + width: 36px; + height: 36px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-detail { + display: flex; + flex-direction: column; + gap: 10px; +} + +.modal-detail-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid var(--border); + font-size: 0.95rem; +} + +.modal-detail-label { + color: var(--text-light); + font-weight: 500; +} + +.modal-detail-value { + font-weight: 600; +} + +.modal-actions { + display: flex; + gap: 10px; + margin-top: 16px; +} + +/* ===== TRANSACTIONS LIST ===== */ +.transaction-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; +} + +.transaction-item:last-child { + border-bottom: none; +} + +.tx-icon { + font-size: 1.3rem; + width: 32px; + text-align: center; +} + +.tx-info { + flex: 1; +} + +.tx-name { + font-weight: 600; +} + +.tx-detail { + font-size: 0.8rem; + color: var(--text-light); +} + +.tx-qty { + font-weight: 700; + font-size: 0.9rem; +} + +.tx-qty.in { + color: var(--success); +} + +.tx-qty.out { + color: var(--danger); +} + +/* ===== PRODUCTS LIST ===== */ +.products-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.product-item { + background: var(--bg-card); + border-radius: var(--radius); + padding: 14px; + box-shadow: var(--shadow); + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; +} + +.product-item:active { + transform: scale(0.98); +} + +/* ===== USE INVENTORY INFO ===== */ +.use-inventory-info { + background: #dbeafe; + border-radius: var(--radius); + padding: 12px 16px; + margin-bottom: 12px; + font-size: 0.9rem; + color: #1d4ed8; + font-weight: 500; +} + +/* ===== EMPTY STATE ===== */ +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); +} + +.empty-state-icon { + font-size: 3rem; + margin-bottom: 12px; +} + +.empty-state p { + font-size: 0.95rem; +} + +/* ===== COMPACT INVENTORY ON DASHBOARD ===== */ +.inventory-list.compact { + gap: 4px; +} + +.compact-item { + padding: 10px 12px; + border-radius: 12px; + box-shadow: none; + border-bottom: 1px solid var(--border); + background: transparent; +} + +.compact-item:last-child { + border-bottom: none; +} + +.compact-item .inv-image { + width: 36px; + height: 36px; + font-size: 1.2rem; +} + +.compact-item .inv-image img { + width: 36px; + height: 36px; +} + +.compact-item .inv-info { + flex: 1; + min-width: 0; +} + +.compact-item .inv-name { + font-size: 0.92rem; +} + +.compact-item .inv-brand { + font-size: 0.75rem; +} + +.inv-qty-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + flex-shrink: 0; +} + +.inv-qty-value { + font-weight: 800; + font-size: 1rem; + color: var(--primary); + white-space: nowrap; +} + +.inv-expiry-small { + font-size: 0.7rem; + color: var(--text-muted); + white-space: nowrap; +} + +.inv-expiry-small.expiring { + color: var(--warning); + font-weight: 600; +} + +.inv-expiry-small.expired { + color: var(--danger); + font-weight: 600; +} + +.section-card h3 { + display: flex; + align-items: center; + gap: 6px; +} + +/* ===== RESPONSIVE ===== */ +@media (min-width: 768px) { + .dashboard-stats { + grid-template-columns: repeat(4, 1fr); + } + + .location-selector { + grid-template-columns: repeat(4, 1fr); + } +} + +/* Safe area for notched phones */ +@supports (padding: env(safe-area-inset-bottom)) { + .bottom-nav { + padding-bottom: env(safe-area-inset-bottom); + height: calc(var(--nav-height) + env(safe-area-inset-bottom)); + } + body { + padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom)); + } +} + +/* ===== PURCHASE TYPE SELECTOR ===== */ +.purchase-type-selector { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 8px 0 12px; +} + +.purchase-type-btn { + padding: 12px 8px; + border: 2px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-card); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-align: center; +} + +.purchase-type-btn.active { + border-color: var(--primary); + background: rgba(45, 80, 22, 0.08); + color: var(--primary); +} + +/* ===== EXPIRY DETAIL ===== */ +.expiry-detail { + margin-top: 8px; + padding: 12px; + background: #f8faf5; + border-radius: var(--radius-sm); + border: 1px solid rgba(45, 80, 22, 0.1); +} + +.expiry-estimate { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + margin-bottom: 8px; +} + +.expiry-estimate-label { + font-size: 0.9rem; + color: var(--text-light); +} + +.expiry-estimate-date { + font-size: 0.85rem; + color: var(--primary); + font-weight: 600; +} + +.form-hint { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 4px; +} + +/* ===== REMAINING QUANTITY OPTIONS ===== */ +.remaining-options { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin-top: 6px; +} + +.remaining-btn { + padding: 10px 4px; + border: 2px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-card); + font-size: 0.9rem; + cursor: pointer; + text-align: center; + transition: all 0.2s; +} + +.remaining-btn.active { + border-color: var(--primary); + background: rgba(45, 80, 22, 0.08); + font-weight: 600; +} + +/* ===== PRODUCT DETAILS CARD (Action Page) ===== */ +.product-details-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 14px; + margin: 0 16px 16px; + box-shadow: var(--shadow); +} + +.product-detail-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.product-detail-tag { + display: inline-block; + padding: 4px 10px; + border-radius: 20px; + background: #f3f4f6; + font-size: 0.8rem; + color: #374151; + white-space: nowrap; +} + +.product-allergens { + padding: 8px 10px; + background: #fef3cd; + border-radius: var(--radius-sm); + font-size: 0.85rem; + margin-bottom: 8px; + color: #856404; +} + +.product-ingredients { + margin-top: 6px; +} + +.product-ingredients summary { + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-light); + padding: 6px 0; +} + +.product-ingredients p { + font-size: 0.8rem; + color: var(--text-light); + line-height: 1.5; + padding: 6px 0; +} + +.product-conservation { + font-size: 0.8rem; + color: var(--text-light); + padding: 6px 0; + border-top: 1px solid var(--border); + margin-top: 6px; +} + +/* ===== QUANTITY + UNIT ROW ===== */ +.qty-unit-row { + display: flex; + gap: 8px; + align-items: center; +} + +.unit-select { + width: 70px; + min-width: 70px; + padding: 10px 4px; + text-align: center; + font-weight: 600; + font-size: 0.95rem; + border-radius: var(--radius-sm); +} + +/* ===== EXPIRY INPUT + CAMERA ROW ===== */ +.expiry-input-row { + display: flex; + gap: 8px; + align-items: center; +} + +.expiry-input-row .form-input { + flex: 1; +} + +.btn-scan-expiry { + padding: 10px 14px !important; + font-size: 1.2rem; + min-width: 48px; + border-radius: var(--radius-sm); +} + +/* ===== EXPIRY SCANNER MODAL ===== */ +.expiry-scanner { + padding: 4px 0; +} + +.expiry-scanner-actions { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..216b45c --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,1697 @@ +/** + * 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' +}; + +// Detect best unit/quantity from Open Food Facts quantity_info string +function detectUnitAndQuantity(quantityInfo) { + if (!quantityInfo) return { unit: 'pz', quantity: 1 }; + const q = quantityInfo.toLowerCase().trim(); + // Match patterns like "500 g", "1 l", "750 ml", "1.5 kg", "6 x 1l" + const multiMatch = q.match(/(\d+)\s*x\s*([\d.,]+)\s*(ml|l|g|kg|cl)/i); + if (multiMatch) { + return { unit: multiMatch[3] === 'cl' ? 'ml' : multiMatch[3], quantity: parseInt(multiMatch[1]), perUnit: multiMatch[2] + multiMatch[3] }; + } + 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: 1, weight: val + unit }; + } + return { unit: 'pz', quantity: 1 }; +} + +// 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; + +// ===== 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 ===== +function showPage(pageId, param = null) { + // 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(); break; + case 'products': loadAllProducts(); break; + case 'ai': initAICamera(); 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, invData] = await Promise.all([ + api('inventory_summary'), + api('stats'), + api('inventory_list') + ]); + + // 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; + } + }); + document.getElementById('stat-total').textContent = total || summary.reduce((a, s) => a + s.product_count, 0); + + // 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 => ` +
+ ${item.name}${item.brand ? ' - ' + item.brand : ''} + ${formatDate(item.expiry_date)} +
+ `).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 => ` +
+ ${item.name}${item.brand ? ' - ' + item.brand : ''} + ${formatDate(item.expiry_date)} +
+ `).join(''); + } else { + expiredSection.style.display = 'none'; + } + + // Full inventory grouped by location + const allItems = invData.inventory || []; + const grouped = { dispensa: [], frigo: [], freezer: [], altro: [] }; + allItems.forEach(item => { + const loc = grouped[item.location] !== undefined ? item.location : 'altro'; + grouped[loc].push(item); + }); + + for (const [loc, items] of Object.entries(grouped)) { + const section = document.getElementById(`dash-section-${loc}`); + const container = document.getElementById(`dash-inv-${loc}`); + if (items.length === 0) { + section.style.display = 'none'; + } else { + section.style.display = 'block'; + container.innerHTML = items.map(item => renderDashItem(item)).join(''); + } + } + + } catch (err) { + console.error('Dashboard load error:', err); + } +} + +function renderDashItem(item) { + const catIcon = CATEGORY_ICONS[item.category] || 'πŸ“¦'; + const isExpired = item.expiry_date && new Date(item.expiry_date) < new Date(); + const isExpiring = item.expiry_date && !isExpired && new Date(item.expiry_date) <= new Date(Date.now() + 7 * 86400000); + const qtyDisplay = formatQuantity(item.quantity, item.unit); + + return ` +
+
+ ${item.image_url ? `` : catIcon} +
+
+
${escapeHtml(item.name)}
+ ${item.brand ? `
${escapeHtml(item.brand)}
` : ''} +
+
+ ${qtyDisplay} + ${item.expiry_date ? `${isExpired ? '⚠️' : ''} ${formatDate(item.expiry_date)}` : ''} +
+
`; +} + +function dashItemTap(inventoryId, productId) { + // Load full inventory so modal works + api('inventory_list').then(data => { + currentInventory = data.inventory || []; + showItemDetail(inventoryId, productId); + }); +} + +function formatQuantity(qty, unit) { + 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'; + // Format nicely + if (n === Math.floor(n)) return `${Math.floor(n)} ${label}`; + return `${n.toFixed(1)} ${label}`; +} + +// ===== 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 renderInventory(items) { + const container = document.getElementById('inventory-list'); + if (items.length === 0) { + container.innerHTML = '
πŸ“­

Nessun prodotto qui.
Scansiona un prodotto per aggiungerlo!

'; + return; + } + container.innerHTML = items.map(item => { + const catIcon = CATEGORY_ICONS[item.category] || 'πŸ“¦'; + const locInfo = LOCATIONS[item.location] || { icon: 'πŸ“¦', label: item.location }; + const isExpired = item.expiry_date && new Date(item.expiry_date) < new Date(); + const isExpiring = item.expiry_date && !isExpired && new Date(item.expiry_date) <= new Date(Date.now() + 7 * 86400000); + const qtyDisplay = formatQuantity(item.quantity, item.unit); + + return ` +
+
+ ${item.image_url ? `` : catIcon} +
+
+
${escapeHtml(item.name)}
+ ${item.brand ? `
${escapeHtml(item.brand)}
` : ''} +
+ ${locInfo.icon} ${locInfo.label} + ${qtyDisplay} + ${item.expiry_date ? `${isExpired ? '⚠️ ' : ''}${formatDate(item.expiry_date)}` : ''} +
+
+
`; + }).join(''); +} + +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[item.category] || 'πŸ“¦'; + + document.getElementById('modal-content').innerHTML = ` + +
+ ${item.image_url ? + `` : + `${catIcon}` + } +
+

${escapeHtml(item.name)}

+

${item.brand ? escapeHtml(item.brand) : ''}

+
+
+ + + `; + document.getElementById('modal-overlay').style.display = 'flex'; +} + +function closeModal() { + document.getElementById('modal-overlay').style.display = 'none'; +} + +async function quickUse(productId, location) { + closeModal(); + currentProduct = { id: productId }; + // Get product info + const data = await api('product_get', { id: productId }); + if (data.product) { + currentProduct = data.product; + } + 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(); + showPage('use'); +} + +async function deleteInventoryItem(id) { + if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) { + await api('inventory_delete', {}, 'POST', { id }); + closeModal(); + showToast('Prodotto rimosso', 'success'); + loadInventory(); + } +} + +function editInventoryItem(id) { + const item = currentInventory.find(i => i.id === id); + if (!item) return; + closeModal(); + + // Show a simple edit modal + document.getElementById('modal-content').innerHTML = ` + +
+
+ +
+ + + +
+
+
+ +
+ ${Object.entries(LOCATIONS).map(([k, v]) => ` + + `).join('')} +
+ +
+
+ + +
+ +
+ `; + document.getElementById('modal-overlay').style.display = 'flex'; +} + +async function submitEditInventory(e, id) { + 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; + + await api('inventory_update', {}, 'POST', { id, quantity: qty, location: loc, expiry_date: expiry }); + closeModal(); + showToast('Aggiornato!', 'success'); + loadInventory(); +} + +// ===== BARCODE SCANNER ===== +async function initScanner() { + const video = document.getElementById('scanner-video'); + const viewport = document.getElementById('scanner-viewport'); + + try { + // Stop any existing stream + stopScanner(); + + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + width: { ideal: 1280 }, + height: { ideal: 720 } + } + }); + + scannerStream = stream; + video.srcObject = stream; + await video.play(); + + // Start Quagga for barcode detection + startQuagga(video); + + } catch (err) { + console.error('Camera error:', err); + document.getElementById('scan-result').style.display = 'block'; + document.getElementById('scan-result').innerHTML = ` +

⚠️ Impossibile accedere alla fotocamera.

+

+ Assicurati di usare HTTPS e di aver concesso i permessi della fotocamera.
+ Puoi inserire il barcode manualmente o usare l'identificazione AI. +

+ `; + } +} + +function startQuagga(videoEl) { + if (quaggaRunning) return; + + const canvas = document.getElementById('scanner-canvas'); + const ctx = canvas.getContext('2d'); + + let scanning = true; + quaggaRunning = true; + let lastDetected = ''; + let detectCount = 0; + + function scanFrame() { + if (!scanning || !scannerStream) return; + + canvas.width = videoEl.videoWidth; + canvas.height = videoEl.videoHeight; + ctx.drawImage(videoEl, 0, 0); + + try { + Quagga.decodeSingle({ + src: canvas.toDataURL('image/jpeg', 0.8), + numOfWorkers: 0, + inputStream: { size: 800 }, + decoder: { + readers: [ + 'ean_reader', + 'ean_8_reader', + 'code_128_reader', + 'code_39_reader', + 'upc_reader', + 'upc_e_reader' + ] + }, + locate: true + }, function(result) { + if (result && result.codeResult) { + const code = result.codeResult.code; + if (code === lastDetected) { + detectCount++; + } else { + lastDetected = code; + detectCount = 1; + } + // Require 2 consecutive reads for reliability + if (detectCount >= 2) { + scanning = false; + quaggaRunning = false; + onBarcodeDetected(code); + return; + } + } + if (scanning) { + setTimeout(scanFrame, 300); + } + }); + } catch (e) { + if (scanning) setTimeout(scanFrame, 500); + } + } + + // Start scanning after a small delay + setTimeout(scanFrame, 500); +} + +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; + 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 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'; + + // Auto-detect name β†’ category when typing + const nameInput = document.getElementById('pf-name'); + nameInput.removeEventListener('input', autoDetectCategory); + nameInput.addEventListener('input', autoDetectCategory); + + showPage('product-form'); +} + +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(); + return; + } + } +} + +function onCategoryChange() { + const cat = document.getElementById('pf-category').value; + const unitSelect = document.getElementById('pf-unit'); + const qtyInput = document.getElementById('pf-defqty'); + + // Mark as manually set if triggered by user click + if (event && event.isTrusted) { + document.getElementById('pf-category').dataset.manuallySet = 'true'; + } + + // Suggest default unit/qty based on category + 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]) { + unitSelect.value = catDefaults[cat].unit; + qtyInput.value = catDefaults[cat].qty; + } +} + +async function submitProduct(e) { + e.preventDefault(); + showLoading(true); + + 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: document.getElementById('pf-unit').value, + default_quantity: parseFloat(document.getElementById('pf-defqty').value) || 1, + 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[currentProduct.category] || 'πŸ“¦'; + const nutriscoreColors = { a: '#1e8f4e', b: '#60ac0e', c: '#eeae0e', d: '#ff6f1e', e: '#e63e11' }; + + let detailsHtml = ''; + + // Weight / quantity info + if (currentProduct.weight_info) { + detailsHtml += `
βš–οΈ ${escapeHtml(currentProduct.weight_info)}
`; + } + + // Nutriscore badge + if (currentProduct.nutriscore) { + const ns = currentProduct.nutriscore.toLowerCase(); + const nsColor = nutriscoreColors[ns] || '#999'; + detailsHtml += `
Nutri-Score ${ns.toUpperCase()}
`; + } + + // NOVA group + if (currentProduct.nova_group) { + const novaLabels = { '1': 'Non trasformato', '2': 'Ingrediente culinario', '3': 'Trasformato', '4': 'Ultra-trasformato' }; + detailsHtml += `
🏭 NOVA ${currentProduct.nova_group}${novaLabels[currentProduct.nova_group] ? ' - ' + novaLabels[currentProduct.nova_group] : ''}
`; + } + + // Ecoscore + if (currentProduct.ecoscore) { + const es = currentProduct.ecoscore.toLowerCase(); + const esColor = nutriscoreColors[es] || '#999'; + detailsHtml += `
🌍 Eco-Score ${es.toUpperCase()}
`; + } + + // Origin + if (currentProduct.origin) { + detailsHtml += `
πŸ“ ${escapeHtml(currentProduct.origin)}
`; + } + + // Labels (bio, DOP, etc.) + if (currentProduct.labels) { + detailsHtml += `
🏷️ ${escapeHtml(currentProduct.labels)}
`; + } + + // Allergens + let allergensHtml = ''; + if (currentProduct.allergens) { + allergensHtml = `
⚠️ Allergeni: ${escapeHtml(currentProduct.allergens)}
`; + } + + // Ingredients (collapsible) + let ingredientsHtml = ''; + if (currentProduct.ingredients) { + const ingredShort = currentProduct.ingredients.length > 120 + ? currentProduct.ingredients.substring(0, 120) + '...' + : currentProduct.ingredients; + ingredientsHtml = ` +
+ πŸ“‹ Ingredienti +

${escapeHtml(currentProduct.ingredients)}

+
+ `; + } + + // Conservation + let conservationHtml = ''; + if (currentProduct.conservation) { + conservationHtml = `
🧊 ${escapeHtml(currentProduct.conservation)}
`; + } + + document.getElementById('action-product-preview').innerHTML = ` + ${currentProduct.image_url ? + `` : + `${catIcon}` + } +
+

${escapeHtml(currentProduct.name)}

+

${currentProduct.brand ? `${escapeHtml(currentProduct.brand)}` : ''}

+ ${currentProduct.barcode ? `

πŸ“Š ${currentProduct.barcode}

` : ''} +
+ `; + + // 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'; + // Insert after preview, before action buttons + const actionBtns = document.querySelector('#page-action .action-buttons'); + actionBtns.parentElement.insertBefore(extraInfoEl, actionBtns); + } + + if (detailsHtml || allergensHtml || ingredientsHtml || conservationHtml) { + extraInfoEl.innerHTML = ` +
+ ${detailsHtml ? `
${detailsHtml}
` : ''} + ${allergensHtml} + ${ingredientsHtml} + ${conservationHtml} +
+ `; + extraInfoEl.style.display = 'block'; + } else { + extraInfoEl.style.display = 'none'; + extraInfoEl.innerHTML = ''; + } + + showPage('action'); +} + +// ===== ADD TO INVENTORY ===== +function showAddForm() { + const catIcon = CATEGORY_ICONS[currentProduct.category] || 'πŸ“¦'; + document.getElementById('add-product-preview').innerHTML = ` + ${currentProduct.image_url ? + `` : + `${catIcon}` + } +
+

${escapeHtml(currentProduct.name)}

+

${currentProduct.brand ? escapeHtml(currentProduct.brand) : ''}

+ ${currentProduct.weight_info ? `

${escapeHtml(currentProduct.weight_info)}

` : ''} +
+ `; + + // Set unit selector + const unit = currentProduct.unit || 'pz'; + const unitSelect = document.getElementById('add-unit'); + unitSelect.value = unit; + + document.getElementById('add-quantity').value = currentProduct.default_quantity || 1; + + // 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 = ` + +
+ + +
+
+
+ Scadenza stimata: ${estimateLabel} + ${formatDate(estimatedDate)} +
+
+ + +
+

πŸ“ Puoi modificare la data o scansionarla con la fotocamera

+
+ `; + + showPage('add'); +} + +function onAddUnitChange() { + updateAddQtyStep(); + // If switching units, suggest a sensible quantity + const unit = document.getElementById('add-unit').value; + const qtyInput = document.getElementById('add-quantity'); + 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; + if (unit === 'g' || unit === 'ml') { + qtyInput.step = '25'; + qtyInput.min = '1'; + } else if (unit === 'kg' || unit === 'l') { + qtyInput.step = '0.25'; + qtyInput.min = '0.1'; + } else { + qtyInput.step = '1'; + qtyInput.min = '1'; + } +} + +function adjustAddQty(delta) { + const qtyInput = document.getElementById('add-quantity'); + const unit = document.getElementById('add-unit').value; + let step; + if (unit === 'g' || unit === 'ml') step = 25; + else if (unit === 'kg' || unit === 'l') step = 0.25; + else step = 1; + let val = parseFloat(qtyInput.value) || 0; + val = Math.max(parseFloat(qtyInput.min) || 0.1, val + delta * step); + qtyInput.value = Math.round(val * 100) / 100; +} + +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'); + + if (type === 'new') { + detailDiv.innerHTML = ` +
+ Scadenza stimata: ${estimateLabel} + ${formatDate(estimatedDate)} +
+
+ + +
+

πŸ“ Puoi modificare la data o scansionarla con la fotocamera

+ `; + } else { + detailDiv.innerHTML = ` +
+ +
+ + +
+

Inserisci la data di scadenza o scansionala

+
+
+ +

Quanto Γ¨ rimasto approssimativamente?

+
+ + + + +
+
+ `; + // Pre-set to 75% + setRemainingPct(0.75); + } +} + +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'; + + 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, + }); + + 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[currentProduct?.category] || 'πŸ“¦'; + document.getElementById('use-product-preview').innerHTML = ` + ${currentProduct?.image_url ? + `` : + `${catIcon}` + } +
+

${escapeHtml(currentProduct?.name || '')}

+

${currentProduct?.brand ? escapeHtml(currentProduct.brand) : ''}

+
+ `; +} + +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 = 'πŸ“¦ Disponibile: ' + items.map(i => { + const loc = LOCATIONS[i.location] || { icon: 'πŸ“¦', label: i.location }; + return `${loc.icon} ${loc.label}: ${i.quantity} ${i.unit}`; + }).join(' Β· '); + } 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'); + 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'); + 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 analyzeBtn = document.getElementById('ai-analyze-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'; + analyzeBtn.style.display = 'none'; + retakeBtn.style.display = 'none'; + resultDiv.style.display = 'none'; + + try { + if (aiStream) { + aiStream.getTracks().forEach(t => t.stop()); + } + aiStream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } + }); + 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-analyze-btn').style.display = 'block'; + document.getElementById('ai-retake-btn').style.display = 'block'; +} + +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 = '

πŸ€– Analisi in corso...

'; + + const canvas = document.getElementById('ai-canvas'); + const imageData = canvas.toDataURL('image/jpeg', 0.7); + + // We'll use a free approach: analyze image colors and shapes locally + // and try to identify using image analysis heuristics + const ctx = canvas.getContext('2d'); + const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + // Simple color analysis to guess product type + let r = 0, g = 0, b = 0; + const pixels = imgData.data; + const count = pixels.length / 4; + for (let i = 0; i < pixels.length; i += 16) { // sample every 4th pixel + r += pixels[i]; + g += pixels[i + 1]; + b += pixels[i + 2]; + } + const samples = count / 4; + r = Math.round(r / samples); + g = Math.round(g / samples); + b = Math.round(b / samples); + + // Provide a manual identification form since free AI APIs are limited + resultDiv.innerHTML = ` +

πŸ€– Identificazione Prodotto

+

+ L'analisi automatica ha dei limiti senza API a pagamento. + Puoi descrivere il prodotto qui sotto e lo salveremo nel database. +

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ `; +} + +async function submitAIProduct(e) { + e.preventDefault(); + showLoading(true); + + const name = document.getElementById('ai-product-name').value; + const brand = document.getElementById('ai-product-brand').value; + const category = document.getElementById('ai-product-category').value; + + // Save the captured image as base64 (we could save to file, but for simplicity use image_url) + const canvas = document.getElementById('ai-canvas'); + // For a lightweight approach, don't store the actual image data in DB + + try { + const result = await api('product_save', {}, 'POST', { + name, brand, category, + unit: 'pz', + default_quantity: 1, + }); + + if (result.success) { + currentProduct = { id: result.id, name, brand, category, unit: 'pz', default_quantity: 1 }; + showLoading(false); + showToast('Prodotto identificato e 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 = '
πŸ“¦

Nessun prodotto nel database.
Scansiona un prodotto per iniziare!

'; + return; + } + container.innerHTML = products.map(p => { + const catIcon = CATEGORY_ICONS[p.category] || 'πŸ“¦'; + return ` +
+
+ ${p.image_url ? `` : catIcon} +
+
+
${escapeHtml(p.name)}
+ ${p.brand ? `
${escapeHtml(p.brand)}
` : ''} +
+ ${p.barcode ? `πŸ“Š ${p.barcode}` : ''} + ${catIcon} ${p.category || 'Non categorizzato'} +
+
+
`; + }).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'); + } +} + +// ===== 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 = ` + +
+
+ + +
+ +

Inquadra la data di scadenza stampata sul prodotto

+ +
+ + +
+
+ `; + document.getElementById('modal-overlay').style.display = 'flex'; + + // Start camera + try { + expiryStream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } + }); + 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 = ` +

⚠️ Impossibile accedere alla fotocamera

+ `; + } +} + +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'); + + 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 (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({ + video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } + }).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 = '

πŸ€– Analisi AI in corso...

'; + + 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 = `

βœ… Data trovata: ${formatDate(result.expiry_date)}

`; + + // Close modal after delay + setTimeout(() => closeExpiryScanner(), 1500); + } else if (result.error === 'no_api_key') { + statusDiv.innerHTML = `

⚠️ Chiave API Gemini non configurata.
Aggiungi GEMINI_API_KEY nel file .env sul server.

`; + } else { + statusDiv.innerHTML = `

❌ Non riesco a leggere la data. ${result.raw_text ? '
Letto: ' + escapeHtml(result.raw_text) + '' : ''}

+ `; + } + } catch (err) { + console.error('Expiry AI error:', err); + statusDiv.innerHTML = `

❌ Errore di connessione. Riprova.

+ `; + } +} + +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 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); +} + +// ===== INITIALIZATION ===== +document.addEventListener('DOMContentLoaded', () => { + showPage('dashboard'); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..4485508 --- /dev/null +++ b/index.html @@ -0,0 +1,480 @@ + + + + + + + + + 🏠 Dispensa Manager + + + + + + + + + +
+
+

🏠 Dispensa

+
+
+ + +
+ + +
+
+
+ πŸ—„οΈ + 0 + Dispensa +
+
+ 🧊 + 0 + Frigo +
+
+ ❄️ + 0 + Freezer +
+
+ πŸ“¦ + 0 + Totale +
+
+ + + + + + +
+

πŸ—„οΈ Dispensa

+
+
+
+

🧊 Frigo

+
+
+
+

❄️ Freezer

+
+
+
+

πŸ“¦ Altro

+
+
+
+ + +
+ +
+ + + + + +
+ +
+
+ + +
+ +
+
+
+
+
+ + +
+ +
+ + +
+

Inquadra il codice a barre del prodotto oppure usa i pulsanti sotto

+
+
+ + +
+ +
+
+ + +
+
+ + +
+ +
+
+
+ +
+ + + + +
+ +
+
+ +
+
+ + + +
+ +
+ +
+
+ +
+ +
+
+ + +
+ +
+
+
+
+ +
+ + + + +
+ +
+
+ +
+ +
+

Oppure specifica la quantitΓ  usata:

+
+ + + +
+ +
+
+
+
+
+ + +
+ +
+ +
+ + + + +
+
+ + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + + +
+
+ + +
+ + +
+
+ + +
+ +
+
+ + +
+ +
+ + + +
+ +

Scatta una foto del prodotto e l'AI cercherΓ  di identificarlo

+
+
+ +
+ + + + + +
+ + + + + + + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..46f950e --- /dev/null +++ b/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Dispensa Manager", + "short_name": "Dispensa", + "description": "Gestione completa della dispensa di casa con scansione barcode", + "start_url": "/dispensa/", + "display": "standalone", + "background_color": "#f0f4e8", + "theme_color": "#2d5016", + "orientation": "portrait", + "icons": [ + { + "src": "data:image/svg+xml,🏠", + "sizes": "any", + "type": "image/svg+xml" + } + ] +}