diff --git a/api/index.php b/api/index.php index 7c5412a..2c6f4e4 100644 --- a/api/index.php +++ b/api/index.php @@ -121,6 +121,25 @@ try { getServerSettings(); break; + // ===== SPESA ONLINE ===== + case 'dupliclick_login': + dupliclickLogin(); + break; + + case 'dupliclick_search': + dupliclickSearch(); + break; + + case 'dupliclick_status': + $tokenFile = __DIR__ . '/../data/dupliclick_token.json'; + if (file_exists($tokenFile)) { + $td = json_decode(file_get_contents($tokenFile), true); + echo json_encode(['logged_in' => !empty($td['token']), 'email' => $td['email'] ?? '']); + } else { + echo json_encode(['logged_in' => false]); + } + break; + default: http_response_code(404); echo json_encode(['error' => 'Unknown action: ' . $action]); @@ -515,6 +534,23 @@ function useFromInventory(PDO $db): void { if ($auth) { $listUUID = $auth['bringListUUID']; $bringName = italianToBring($product['name']); + + // Check if already on the Bring! list + $alreadyOnList = false; + $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); + if ($listData && isset($listData['purchase'])) { + foreach ($listData['purchase'] as $existingItem) { + if (strcasecmp($existingItem['name'] ?? '', $bringName) === 0) { + $alreadyOnList = true; + break; + } + } + } + + if ($alreadyOnList) { + // Already on the list, skip adding + $addedToBring = false; + } else { $spec = $product['brand'] ?: ''; $body = http_build_query([ 'uuid' => $listUUID, @@ -529,6 +565,7 @@ function useFromInventory(PDO $db): void { $logStmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'bring', 0, '', 'Auto-aggiunto a Bring!')"); $logStmt->execute([$productId]); } + } // end else (not already on list) } } catch (Exception $e) { // Silently fail — don't block inventory operation @@ -1054,6 +1091,15 @@ function generateRecipe(PDO $db): void { $line .= " [scade tra $daysLeft giorni - priorità media]"; } } + // Flag fridge items for priority + if (strtolower($item['location']) === 'frigo') { + $line .= " [IN FRIGO - PRIORITÀ]"; + } + // Flag opened packages (fractional quantity = already opened) + $qty = floatval($item['quantity']); + if ($qty > 0 && $qty < 1 && $item['unit'] === 'conf') { + $line .= " [CONFEZIONE APERTA - USA PRIMA]"; + } $line .= " (in {$item['location']})"; $ingredientLines[] = $line; } @@ -1106,13 +1152,14 @@ Sei un nutrizionista e chef italiano esperto. Genera UNA ricetta per $mealLabel REGOLE IMPORTANTI: 1. PRIORITÀ ASSOLUTA: usa prima gli ingredienti in scadenza o già scaduti (se ancora utilizzabili) -2. Prediligi una ricetta SANA, EQUILIBRATA e NUTRIENTE -3. Usa SOLO ingredienti dalla lista sotto, più al massimo acqua, sale, pepe e olio che si presumono sempre disponibili -4. Adatta le quantità per $persons persona/e -5. Se non ci sono abbastanza ingredienti per una ricetta completa, suggerisci la migliore combinazione possibile -6. La ricetta deve essere adatta al pasto: $mealLabel -7. IMPORTANTE - QUANTITÀ NUMERICHE: per ogni ingrediente dalla dispensa, il campo "qty_number" DEVE contenere il valore NUMERICO da scalare dall'inventario, espresso nella STESSA unità di misura della dispensa. Esempio: se in dispensa c'è "Farina: 1000 g" e la ricetta richiede 200g, qty_number = 200. Se "Riso: 2 kg" e servono 300g, qty_number = 0.3. Per ingredienti non dalla dispensa, qty_number = 0. -8. GESTIONE SMART QUANTITÀ: NON lasciare rimasugli poco usabili in dispensa. Se un ingrediente ha una quantità piccola (es. 50g di formaggio, 1 uovo, 100ml di latte), preferisci usarlo TUTTO piuttosto che lasciarne una quantità inutilizzabile. Se invece la quantità è abbondante, usa solo il necessario lasciando abbastanza per un altro pasto. Pensa sempre: "quello che resta sarà sufficiente per un altro utilizzo?" +2. PRIORITÀ ALTA: preferisci ingredienti in FRIGO (contrassegnati [IN FRIGO]) e quelli con CONFEZIONE APERTA (contrassegnati [CONFEZIONE APERTA]). Questi si deteriorano più in fretta e vanno usati prima. +3. Prediligi una ricetta SANA, EQUILIBRATA e NUTRIENTE +4. Usa SOLO ingredienti dalla lista sotto, più al massimo acqua, sale, pepe e olio che si presumono sempre disponibili +5. Adatta le quantità per $persons persona/e +6. Se non ci sono abbastanza ingredienti per una ricetta completa, suggerisci la migliore combinazione possibile +7. La ricetta deve essere adatta al pasto: $mealLabel +8. IMPORTANTE - QUANTITÀ NUMERICHE: per ogni ingrediente dalla dispensa, il campo "qty_number" DEVE contenere il valore NUMERICO da scalare dall'inventario, espresso nella STESSA unità di misura della dispensa. Esempio: se in dispensa c'è "Farina: 1000 g" e la ricetta richiede 200g, qty_number = 200. Se "Riso: 2 kg" e servono 300g, qty_number = 0.3. Per ingredienti non dalla dispensa, qty_number = 0. +9. GESTIONE SMART QUANTITÀ: NON lasciare rimasugli poco usabili in dispensa. Se un ingrediente ha una quantità piccola (es. 50g di formaggio, 1 uovo, 100ml di latte), preferisci usarlo TUTTO piuttosto che lasciarne una quantità inutilizzabile. Se invece la quantità è abbondante, usa solo il necessario lasciando abbastanza per un altro pasto. Pensa sempre: "quello che resta sarà sufficiente per un altro utilizzo?" INGREDIENTI DISPONIBILI IN DISPENSA: $ingredientsText @@ -1716,8 +1763,18 @@ function bringAddItems(): void { } $added = 0; + $skipped = 0; $errors = []; + // Fetch current list to check for duplicates + $existingNames = []; + $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); + if ($listData && isset($listData['purchase'])) { + foreach ($listData['purchase'] as $existingItem) { + $existingNames[] = strtolower($existingItem['name'] ?? ''); + } + } + foreach ($items as $item) { $name = $item['name'] ?? ''; $spec = $item['specification'] ?? ''; @@ -1726,6 +1783,12 @@ function bringAddItems(): void { // Map Italian name to Bring! catalog key (German) for proper recognition $bringName = italianToBring($name); + // Skip if already on the list + if (in_array(strtolower($bringName), $existingNames)) { + $skipped++; + continue; + } + $body = http_build_query([ 'uuid' => $listUUID, 'purchase' => $bringName, @@ -1740,7 +1803,7 @@ function bringAddItems(): void { } } - echo json_encode(['success' => true, 'added' => $added, 'errors' => $errors]); + echo json_encode(['success' => true, 'added' => $added, 'skipped' => $skipped, 'errors' => $errors]); } function bringRemoveItem(): void { @@ -1989,3 +2052,309 @@ PROMPT; 'listUUID' => $listUUID, ]); } + +// ===== DUPLICLICK (GRUPPO POLI) ===== + +function dupliclickLogin(): void { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + echo json_encode(['error' => 'POST required']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + $email = $input['email'] ?? ''; + $password = $input['password'] ?? ''; + + if (empty($email) || empty($password)) { + echo json_encode(['error' => 'Email e password sono obbligatori']); + return; + } + + $postData = http_build_query([ + 'login' => $email, + 'password' => $password, + 'remember_me' => 'true', + 'show_sectors' => 'false' + ]); + + $ch = curl_init('https://www.dupliclick.it/ebsn/api/auth/login'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postData, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/x-www-form-urlencoded;charset=UTF-8', + 'Accept: application/json', + 'Origin: https://www.dupliclick.it', + 'Referer: https://www.dupliclick.it/', + 'x-ebsn-client: production', + 'x-ebsn-client-redirect: production', + 'x-ebsn-client-uuid: 64b2d6318bb8f97bb1aba47dd8af38f6', + 'x-ebsn-version: 2.0.7' + ], + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $response = curl_exec($ch); + + if (curl_errno($ch)) { + echo json_encode(['error' => 'Errore connessione: ' . curl_error($ch)]); + curl_close($ch); + return; + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $headerStr = substr($response, 0, $headerSize); + $body = substr($response, $headerSize); + curl_close($ch); + + // Extract JWT token from x-ebsn-account header + $token = ''; + foreach (explode("\r\n", $headerStr) as $line) { + if (stripos($line, 'x-ebsn-account:') === 0) { + $token = trim(substr($line, strlen('x-ebsn-account:'))); + break; + } + } + + // The response body may have leading whitespace/newlines - trim it + $body = trim($body); + $bodyData = json_decode($body, true); + + // Check login success: status is at response.status (not root level) + if ($bodyData === null) { + echo json_encode(['error' => 'Risposta non valida dal server DupliClick', 'http_code' => $httpCode, 'raw' => substr($body, 0, 500)]); + return; + } + + $respStatus = $bodyData['response']['status'] ?? ($bodyData['status'] ?? -1); + if ($respStatus !== 0) { + $errors = $bodyData['response']['errors'] ?? $bodyData['errors'] ?? []; + $errMsg = $errors[0]['error'] ?? $bodyData['message'] ?? 'Credenziali non valide'; + echo json_encode(['error' => $errMsg, 'status' => $respStatus]); + return; + } + + // User data is at root level, not inside data.user + $userData = $bodyData['data']['user'] ?? $bodyData['user'] ?? null; + $cartId = $bodyData['data']['cartId'] ?? $bodyData['cartId'] ?? null; + + // Save token to file for later use + $tokenData = [ + 'token' => $token, + 'email' => $email, + 'logged_at' => date('c'), + 'user' => $userData, + 'cart_id' => $cartId, + ]; + file_put_contents(__DIR__ . '/../data/dupliclick_token.json', json_encode($tokenData, JSON_PRETTY_PRINT)); + + echo json_encode([ + 'success' => true, + 'token' => !empty($token) ? substr($token, 0, 20) . '...' : '(non trovato)', + 'token_full' => $token, + 'http_code' => $httpCode, + 'data' => $bodyData['data'] ?? null, + 'user' => $userData, + 'response_status' => $respStatus, + 'infos' => $bodyData['response']['infos'] ?? [], + ]); +} + +// ===== DUPLICLICK PRODUCT SEARCH ===== + +function dupliclickSearch(): void { + $query = $_GET['q'] ?? ''; + $spec = $_GET['spec'] ?? ''; + $aiPrompt = $_GET['prompt'] ?? ''; + if (empty($query)) { + echo json_encode(['error' => 'Parametro q obbligatorio']); + return; + } + + // Load saved token + $tokenFile = __DIR__ . '/../data/dupliclick_token.json'; + if (!file_exists($tokenFile)) { + echo json_encode(['error' => 'Non sei loggato a DupliClick. Vai in Configurazione > Spesa Online.']); + return; + } + $tokenData = json_decode(file_get_contents($tokenFile), true); + $token = $tokenData['token'] ?? ''; + if (empty($token)) { + echo json_encode(['error' => 'Token DupliClick non trovato. Effettua il login.']); + return; + } + + $baseHeaders = [ + 'Accept: application/json', + 'Origin: https://www.dupliclick.it', + 'Referer: https://www.dupliclick.it/', + 'x-ebsn-client: production', + 'x-ebsn-client-uuid: 64b2d6318bb8f97bb1aba47dd8af38f6', + 'x-ebsn-version: 2.0.7', + 'x-ebsn-account: ' . $token, + ]; + + // Search catalog by item name only (spec confuses the search engine) + $url = 'https://www.dupliclick.it/ebsn/api/products?' . http_build_query([ + 'q' => $query, + 'page' => 1, + 'order_by' => 'search_score desc' + ]); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_HTTPHEADER => $baseHeaders, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $response = curl_exec($ch); + if (curl_errno($ch)) { + echo json_encode(['error' => 'Errore connessione DupliClick: ' . curl_error($ch)]); + curl_close($ch); + return; + } + curl_close($ch); + + $data = json_decode(trim($response), true); + if (!$data || ($data['response']['status'] ?? -1) !== 0) { + echo json_encode(['error' => 'Errore nella ricerca', 'details' => $data['response'] ?? null]); + return; + } + + $products = $data['data']['products'] ?? []; + if (empty($products)) { + echo json_encode(['success' => true, 'query' => $query, 'product' => null, 'total' => 0]); + return; + } + + // Format top 10 products + $topProducts = array_slice($products, 0, 10); + $formatted = array_map('formatDupliclickProduct', $topProducts); + $total = $data['data']['page']['totItems'] ?? 0; + + // If multiple results, use AI to pick the best match + $bestProduct = $formatted[0]; + $aiUsed = false; + if (count($formatted) > 1) { + $aiResult = aiSelectBestProduct($query, $spec, $formatted, $aiPrompt); + if ($aiResult !== null) { + $bestProduct = $aiResult; + $aiUsed = true; + } + } + + echo json_encode([ + 'success' => true, + 'query' => $query, + 'product' => $bestProduct, + 'total' => $total, + 'ai_used' => $aiUsed, + ]); +} + +/** + * Use Gemini AI to pick the best product from search results + */ +function aiSelectBestProduct(string $itemName, string $spec, array $products, string $customPrompt = ''): ?array { + $env = loadEnvVars(); + $apiKey = $env['GEMINI_API_KEY'] ?? ''; + if (empty($apiKey)) return null; + + $defaultPrompt = "Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare e una lista di prodotti trovati nel catalogo del supermercato. + +Regole di selezione: +- Scegli il prodotto che corrisponde ESATTAMENTE a quello richiesto (stessa categoria merceologica) +- Preferisci prodotti freschi/sfusi rispetto a trasformati (es. \"Arance\" = arance frutta, NON aranciata bevanda) +- Se c'è una descrizione (es. \"a cubetti\", \"biologico\"), trova il prodotto che include quella caratteristica +- Se ci sono più varianti valide, scegli quella con il miglior rapporto qualità/prezzo +- Preferisci formati standard per una famiglia +- NON scegliere mai un prodotto di categoria diversa (bevanda vs frutta, surgelato vs fresco, condimento vs ortaggio, ecc.) +- \"Finocchio\" = ortaggio fresco, NON semi di finocchio o tisana +- \"Arance\" = frutta fresca, NON aranciata o succo + +Rispondi SOLO con il numero (indice 0-based) del prodotto migliore, oppure -1 se nessun prodotto è appropriato."; + + $prompt = !empty($customPrompt) ? $customPrompt : $defaultPrompt; + + // Build product list + $productList = ''; + foreach ($products as $i => $p) { + $productList .= "[$i] \"{$p['name']}\" - {$p['brand']} - €" . number_format($p['price'], 2) . " - {$p['packageDescr']}\n"; + } + + $fullPrompt = "{$prompt}\n\nProdotto cercato: \"{$itemName}\"" . ($spec ? " ({$spec})" : '') . "\n\nProdotti trovati:\n{$productList}\nRispondi SOLO con il numero (es. 0, 1, 2... oppure -1):"; + + $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; + $payload = json_encode([ + 'contents' => [['parts' => [['text' => $fullPrompt]]]], + 'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 16], + ]); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($response === false || $httpCode !== 200) return null; + + $data = json_decode($response, true); + $text = trim($data['candidates'][0]['content']['parts'][0]['text'] ?? ''); + if (preg_match('/-?\d+/', $text, $m)) { + $idx = (int)$m[0]; + if ($idx >= 0 && $idx < count($products)) { + return $products[$idx]; + } elseif ($idx === -1) { + return null; // AI says nothing matches + } + } + + return null; // Could not parse, caller will use first result +} + +function formatDupliclickProduct(array $p): array { + $promo = $p['warehousePromo'] ?? null; + $result = [ + 'productId' => $p['productId'] ?? $p['id'] ?? null, + 'name' => $p['name'] ?? '', + 'brand' => $p['shortDescr'] ?? '', + 'price' => $p['price'] ?? 0, + 'priceDisplay' => $p['priceDisplay'] ?? $p['price'] ?? 0, + 'priceUm' => $p['priceStandardUmDisplay'] ?? null, + 'weightUnit' => $p['weightUnitDisplay'] ?? '', + 'packageDescr' => $p['productInfos']['PACKAGE_DESCR'] ?? '', + 'barcode' => $p['barcode'] ?? '', + 'imageUrl' => $p['mediaURL'] ?? '', + 'slug' => $p['slug'] ?? '', + 'itemUrl' => $p['itemUrl'] ?? '', + 'url' => 'https://www.dupliclick.it' . ($p['itemUrl'] ?? ''), + 'available' => $p['available'] ?? 0, + ]; + + if ($promo) { + $result['promo'] = [ + 'discount' => $promo['discount'] ?? 0, + 'discountPerc' => $promo['discountPerc'] ?? 0, + 'originalPrice' => round(($p['price'] ?? 0) + ($promo['discount'] ?? 0), 2), + 'validFrom' => $promo['validityDate'] ?? '', + 'validTo' => $promo['expireDate'] ?? '', + 'label' => $promo['view']['body'] ?? 'OFFERTA', + 'type' => $promo['promoType'] ?? '', + ]; + } + + return $result; +} diff --git a/assets/css/style.css b/assets/css/style.css index a8c2084..94233c0 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -1054,7 +1054,7 @@ body { .shopping-item { display: flex; - align-items: center; + align-items: flex-start; gap: 10px; padding: 10px 12px; background: var(--bg-card); @@ -1065,6 +1065,18 @@ body { .shopping-item-icon { font-size: 1.3rem; flex-shrink: 0; + margin-top: 2px; +} + +.shopping-item-body { + flex: 1; + min-width: 0; +} + +.shopping-item-top { + display: flex; + align-items: stretch; + gap: 8px; } .shopping-item-info { @@ -1082,15 +1094,92 @@ body { color: var(--text-muted); } +.shopping-item-right { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + flex-shrink: 0; + min-width: 70px; +} + +.shopping-item-price { + font-weight: 800; + font-size: 1.25rem; + color: var(--accent); + line-height: 1.1; + white-space: nowrap; + margin-top: auto; +} + .shopping-item-remove { background: none; border: none; color: var(--text-muted); - font-size: 1rem; - padding: 4px 8px; + font-size: 0.85rem; + padding: 2px 4px; cursor: pointer; border-radius: 50%; transition: background 0.2s; + flex-shrink: 0; + line-height: 1; +} + +.shopping-item-remove:hover, +.shopping-item-remove:active { + background: #fee2e2; + color: #dc2626; +} + +/* Spesa bar: action buttons below item */ +.spesa-bar { + display: flex; + gap: 6px; + margin-top: 5px; + flex-wrap: wrap; +} + +.spesa-bar-btn { + font-size: 0.7rem; + padding: 3px 8px; + border: 1px solid #e0e0e0; + border-radius: 12px; + background: #f8f9fa; + color: var(--text-secondary); + cursor: pointer; + text-decoration: none; + transition: background 0.2s; + white-space: nowrap; +} + +.spesa-bar-btn:hover, +.spesa-bar-btn:active { + background: #e0f2fe; + border-color: var(--accent); +} + +/* Loading animation */ +.spesa-loading { + font-size: 0.8rem; + color: var(--accent); + margin-top: 3px; + animation: spesaPulse 1.2s ease-in-out infinite; +} + +@keyframes spesaPulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + +/* Found product name */ +.spesa-found-name { + font-size: 0.72rem; + color: var(--text-muted); + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; } .shopping-item-remove:hover, @@ -1101,6 +1190,15 @@ body { .shopping-actions { margin-top: 8px; + display: flex; + flex-direction: row; + gap: 8px; +} + +.shopping-actions .btn { + flex: 1; + font-size: 0.8rem; + padding: 10px 8px; } .seasonal-tip { @@ -2321,12 +2419,13 @@ body { background: var(--bg-card); border: 2px solid var(--border); border-radius: 25px; - padding: 8px 14px; - font-size: 0.82rem; + padding: 7px 12px; + font-size: 0.78rem; font-weight: 600; white-space: nowrap; cursor: pointer; transition: all 0.2s; + flex-shrink: 0; } .settings-tab.active { @@ -3025,3 +3124,279 @@ body { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-6px); opacity: 1; } } + +/* ===== SPESA ONLINE / DUPLICLICK ===== */ +.provider-selector { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.provider-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 14px 20px; + border: 2px solid var(--border); + border-radius: 12px; + background: var(--card-bg); + cursor: pointer; + transition: all 0.2s; + min-width: 120px; +} + +.provider-btn:hover { + border-color: var(--accent); + background: rgba(45, 80, 22, 0.05); +} + +.provider-btn.active { + border-color: var(--accent); + background: rgba(45, 80, 22, 0.1); + box-shadow: 0 0 0 3px rgba(45, 80, 22, 0.15); +} + +.provider-icon { + font-size: 1.8rem; +} + +.provider-name { + font-weight: 700; + font-size: 0.95rem; + color: var(--text); +} + +.provider-desc { + font-size: 0.75rem; + color: var(--text-muted); +} + +.dupliclick-status { + margin-top: 12px; + padding: 10px 14px; + border-radius: 10px; + font-size: 0.9rem; +} + +.dupliclick-status.success { + background: #dcfce7; + color: #166534; + border: 1px solid #86efac; +} + +.dupliclick-status.error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fca5a5; +} + +.dupliclick-data { + margin-top: 16px; +} + +.dupliclick-data h4 { + margin-bottom: 12px; + font-size: 1rem; + color: var(--text); +} + +.dupliclick-data-grid { + display: flex; + flex-direction: column; + gap: 6px; +} + +.data-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 0.85rem; +} + +.data-label { + font-weight: 600; + color: var(--text-muted); + min-width: 120px; +} + +.data-value { + text-align: right; + color: var(--text); + word-break: break-all; + max-width: 60%; +} + +.data-value.token-value { + font-family: monospace; + font-size: 0.8rem; + color: var(--accent); +} + +.raw-data-details { + margin-top: 12px; +} + +.raw-data-details summary { + cursor: pointer; + font-weight: 600; + padding: 8px 0; + color: var(--accent); + font-size: 0.85rem; +} + +.raw-json { + margin-top: 8px; + padding: 12px; + background: #1e293b; + color: #e2e8f0; + border-radius: 10px; + font-size: 0.75rem; + line-height: 1.5; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; +} + +/* ===== SPESA ONLINE - PRICE IN SHOPPING LIST ===== */ +.spesa-total-banner { + background: linear-gradient(135deg, #065f46, #047857); + border-radius: var(--radius); + padding: 14px 16px; + margin-bottom: 12px; + color: white; +} + +.spesa-total-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.spesa-total-label { + font-size: 0.95rem; + font-weight: 600; +} + +.spesa-total-value { + font-size: 1.4rem; + font-weight: 800; + letter-spacing: -0.5px; +} + +.spesa-total-detail { + font-size: 0.75rem; + opacity: 0.85; + margin-top: 4px; +} + +.spesa-detail-left { + display: flex; + flex-direction: column; + gap: 1px; + margin-top: 3px; +} + +.spesa-pkg { + font-size: 0.68rem; + color: var(--text-muted); +} + +.spesa-promo-badge { + font-size: 0.65rem; + font-weight: 700; + background: #dc2626; + color: white; + padding: 1px 5px; + border-radius: 4px; + white-space: nowrap; +} + +.spesa-promo-expire { + font-size: 0.6rem; + color: var(--text-muted); + font-style: italic; +} + +.spesa-not-found { + font-size: 0.7rem; + color: var(--text-muted); + font-style: italic; +} + +.shopping-item.has-promo { + border-left: 3px solid #dc2626; +} + +/* ====== Recipe Archive ====== */ +.recipe-archive { + display: flex; + flex-direction: column; + gap: 16px; + padding-bottom: 16px; +} + +.recipe-archive-day { + display: flex; + flex-direction: column; + gap: 8px; +} + +.recipe-archive-date { + font-weight: 700; + font-size: 0.95rem; + color: var(--text-primary); + padding: 2px 0; + border-bottom: 2px solid var(--accent); + margin-bottom: 2px; +} + +.recipe-archive-card { + background: var(--bg-card); + border-radius: 10px; + padding: 10px 14px; + cursor: pointer; + transition: box-shadow 0.2s, transform 0.15s; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} + +.recipe-archive-card:hover, +.recipe-archive-card:active { + box-shadow: 0 2px 8px rgba(0,0,0,0.12); + transform: translateY(-1px); +} + +.recipe-archive-card-header { + display: flex; + align-items: center; + gap: 8px; +} + +.recipe-archive-meal { + font-size: 1.3rem; + flex-shrink: 0; +} + +.recipe-archive-title { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recipe-archive-card-meta { + display: flex; + gap: 10px; + margin-top: 4px; + font-size: 0.72rem; + color: var(--text-muted); + flex-wrap: wrap; +} diff --git a/assets/js/app.js b/assets/js/app.js index 6aadb59..ce6e705 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -341,6 +341,7 @@ async function loadSettingsUI() { document.getElementById('setting-pref-zerowaste').checked = !!s.pref_zerowaste; document.getElementById('setting-dietary').value = s.dietary || ''; renderAppliances(s.appliances || []); + loadSpesaSettings(); // Load server-side settings if not already set locally try { @@ -419,6 +420,9 @@ async function saveSettings() { s.pref_comfort = document.getElementById('setting-pref-comfort').checked; s.pref_zerowaste = document.getElementById('setting-pref-zerowaste').checked; s.dietary = document.getElementById('setting-dietary').value.trim(); + // Save spesa AI prompt if the field exists + const spesaPromptEl = document.getElementById('setting-spesa-ai-prompt'); + if (spesaPromptEl) s.spesa_ai_prompt = spesaPromptEl.value.trim(); saveSettingsToStorage(s); // Also save to server .env @@ -518,6 +522,7 @@ function showPage(pageId, param = null) { case 'scan': initScanner(); clearQuickNameResults(); break; case 'products': loadAllProducts(); break; case 'shopping': loadShoppingList(); break; + case 'recipe': loadRecipeArchive(); break; case 'log': loadLog(); break; case 'ai': initAICamera(); break; case 'settings': loadSettingsUI(); break; @@ -2848,6 +2853,97 @@ async function selectProductForAction(productId) { let shoppingListUUID = ''; let shoppingItems = []; let suggestionItems = []; +let shoppingPrices = {}; // { itemName: { product, searched: true } } + +const DEFAULT_SPESA_AI_PROMPT = `Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare e una lista di prodotti trovati nel catalogo del supermercato. + +Regole di selezione: +- Scegli il prodotto che corrisponde ESATTAMENTE a quello richiesto (stessa categoria merceologica) +- Preferisci prodotti freschi/sfusi rispetto a trasformati (es. "Arance" = arance frutta, NON aranciata bevanda) +- Se c'è una descrizione (es. "a cubetti", "biologico"), trova il prodotto che include quella caratteristica +- Se ci sono più varianti valide, scegli quella con il miglior rapporto qualità/prezzo +- Preferisci formati standard per una famiglia +- NON scegliere mai un prodotto di categoria diversa (bevanda vs frutta, surgelato vs fresco, condimento vs ortaggio, ecc.) +- "Finocchio" = ortaggio fresco, NON semi di finocchio o tisana +- "Arance" = frutta fresca, NON aranciata o succo + +Rispondi SOLO con il numero (indice 0-based) del prodotto migliore, oppure -1 se nessun prodotto è appropriato.`; + +function saveShoppingPrices() { + try { + // Only save items that have been searched (not loading state) + const toSave = {}; + for (const [k, v] of Object.entries(shoppingPrices)) { + if (v.searched) toSave[k] = v; + } + localStorage.setItem('dispensa_shopping_prices', JSON.stringify(toSave)); + } catch (e) { /* quota exceeded or private mode */ } +} + +function loadShoppingPrices() { + try { + const raw = localStorage.getItem('dispensa_shopping_prices'); + if (raw) shoppingPrices = JSON.parse(raw); + } catch (e) { shoppingPrices = {}; } +} + +// Build a better search query from item name + specification +function buildSearchQuery(item) { + // Only use the item name for search - specification confuses the search engine + // The AI on the backend will use the specification to pick the right product + return item.name; +} + +// Parse weight/quantity from specification (e.g. "200g" -> 0.2 kg, "500 ml" -> 0.5, "2 pz" -> 2 units) +function parseQtyFromSpec(spec) { + if (!spec) return null; + const s = spec.toLowerCase().trim(); + // Match weight/volume: 200g, 0.5kg, 500 g, 1,5 kg, 200 gr + const m = s.match(/(\d+[.,]?\d*)\s*(g|gr|kg|ml|cl|l|lt)/i); + if (m) { + let val = parseFloat(m[1].replace(',', '.')); + const unit = m[2].toLowerCase(); + if (unit === 'g' || unit === 'gr') return { kg: val / 1000, label: val + 'g', type: 'weight' }; + if (unit === 'kg') return { kg: val, label: val + 'kg', type: 'weight' }; + if (unit === 'ml') return { kg: val / 1000, label: val + 'ml', type: 'weight' }; + if (unit === 'cl') return { kg: val / 100, label: val * 10 + 'ml', type: 'weight' }; + if (unit === 'l' || unit === 'lt') return { kg: val, label: val + 'L', type: 'weight' }; + } + // Match unit count: 2 pz, 3 pezzi, 5, 2x, ~5 pz + const pzMatch = s.match(/~?(\d+)\s*(pz|pezzi|x|$)/i); + if (pzMatch) { + const count = parseInt(pzMatch[1]); + if (count > 0 && count <= 50) return { count, label: count + ' pz', type: 'units' }; + } + return null; +} + +// Estimate price when product is sold per-kg/per-L or per-unit and user wants a certain quantity +function estimateItemPrice(product, spec) { + if (!product.priceUm) return null; + const umStr = String(product.priceUm); + const pm = umStr.match(/(\d+[.,]?\d*)/); + if (!pm) return null; + const pricePerUnit = parseFloat(pm[1].replace(',', '.')); + if (!pricePerUnit || pricePerUnit <= 0) return null; + + const qty = parseQtyFromSpec(spec); + if (!qty) return null; + + if (qty.type === 'weight') { + const estimated = pricePerUnit * qty.kg; + if (estimated <= 0 || estimated > 500) return null; + return { estimated: Math.round(estimated * 100) / 100, qtyLabel: qty.label }; + } else if (qty.type === 'units') { + // For unit items: estimate per-item cost from the product price + // If product is per-kg and we want N pieces, estimate ~200-300g per piece + const avgWeightPerPiece = 0.25; // ~250g per piece (fruit/veg average) + const estimated = pricePerUnit * avgWeightPerPiece * qty.count; + if (estimated <= 0 || estimated > 500) return null; + return { estimated: Math.round(estimated * 100) / 100, qtyLabel: qty.label }; + } + return null; +} // Load just the shopping count for dashboard stat card async function loadShoppingCount() { @@ -2886,6 +2982,18 @@ async function loadShoppingList() { shoppingListUUID = data.listUUID; shoppingItems = data.purchase || []; + // Clean up shoppingPrices for items no longer on the list + const currentKeys = new Set(shoppingItems.map(i => i.name.toLowerCase())); + let pricesChanged = false; + for (const key of Object.keys(shoppingPrices)) { + if (!currentKeys.has(key)) { + delete shoppingPrices[key]; + pricesChanged = true; + } + } + if (pricesChanged) saveShoppingPrices(); + + loadShoppingPrices(); renderShoppingItems(); currentEl.style.display = 'block'; @@ -2896,7 +3004,7 @@ async function loadShoppingList() { } } -function renderShoppingItems() { +async function renderShoppingItems() { const container = document.getElementById('shopping-items'); const countEl = document.getElementById('shopping-count'); @@ -2904,21 +3012,245 @@ function renderShoppingItems() { if (shoppingItems.length === 0) { container.innerHTML = '
Lista della spesa vuota!
Usa il pulsante sotto per generare suggerimenti.
Nessuna ricetta salvata.
Genera la tua prima ricetta!
Configura il provider per la spesa online.
+L'AI usa questo prompt per scegliere il prodotto più appropriato tra i risultati. Lascia vuoto per il comportamento predefinito.
+Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.
+ +