From 3a7fce49a0e32bde1069430c21daf37482a4b0a4 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Thu, 12 Mar 2026 12:06:02 +0000 Subject: [PATCH] Shopping list improvements: dedup Bring items, sync removals, server-side DupliClick token check, redesigned price layout, recipe archive with CSS, fridge/opened priority in recipes, better search with spec in query --- api/index.php | 385 ++++++++++++++++++++++++++++- assets/css/style.css | 385 ++++++++++++++++++++++++++++- assets/js/app.js | 558 ++++++++++++++++++++++++++++++++++++++++++- index.html | 107 ++++++++- 4 files changed, 1406 insertions(+), 29 deletions(-) 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.

'; + updateSpesaTotal(); return; } + const s = getSettings(); + let hasSpesa = s.spesa_logged_in && s.spesa_token; + + // If not logged in locally, check server-side token + if (!hasSpesa) { + try { + const status = await api('dupliclick_status'); + if (status.logged_in) { + hasSpesa = true; + s.spesa_logged_in = true; + s.spesa_token = 'server'; + s.spesa_user = status.email || ''; + saveSettings(s); + } + } catch (e) { /* ignore */ } + } + container.innerHTML = shoppingItems.map((item, idx) => { const catIcon = CATEGORY_ICONS[guessCategoryFromName(item.name)] || '🛒'; + const priceKey = item.name.toLowerCase(); + const priceData = shoppingPrices[priceKey]; + + let detailHtml = ''; + let priceTag = ''; + let spesaBar = ''; + if (hasSpesa) { + if (priceData && priceData.loading) { + detailHtml = `
🔍 Cerco...
`; + } else if (priceData && priceData.product) { + const p = priceData.product; + const promoHtml = p.promo + ? `${escapeHtml(p.promo.label)} -${Math.round(p.promo.discountPerc)}%` + : ''; + const est = estimateItemPrice(p, item.specification || priceData.spec || ''); + if (est) { + priceTag = `
~€${est.estimated.toFixed(2)}
`; + } else { + priceTag = `
€${p.price.toFixed(2)}
`; + } + detailHtml = `
+ ${escapeHtml(p.name)} + ${escapeHtml(p.packageDescr)}${est ? ' · ' + escapeHtml(String(p.priceUm || '')) + '/kg' : ''} + ${promoHtml} +
`; + spesaBar = `
+ + 🔗 Apri +
`; + } else if (priceData && priceData.searched && !priceData.product) { + detailHtml = `
Non trovato
`; + spesaBar = `
+ +
`; + } else { + spesaBar = `
+ +
`; + } + } + return ` -
+
${catIcon} -
-
${escapeHtml(item.name)}
- ${item.specification ? `
${escapeHtml(item.specification)}
` : ''} +
+
+
+
${escapeHtml(item.name)}
+ ${item.specification ? `
${escapeHtml(item.specification)}
` : ''} + ${detailHtml} +
+
+ ${priceTag} + +
+
+ ${spesaBar}
-
`; }).join(''); + + updateSpesaTotal(); +} + +function updateSpesaTotal() { + const banner = document.getElementById('spesa-total-banner'); + const valueEl = document.getElementById('spesa-total-value'); + const detailEl = document.getElementById('spesa-total-detail'); + + let total = 0; + let found = 0; + let promoSaved = 0; + + for (const item of shoppingItems) { + const pd = shoppingPrices[item.name.toLowerCase()]; + if (pd && pd.product) { + const est = estimateItemPrice(pd.product, item.specification || pd.spec || ''); + total += est ? est.estimated : pd.product.price; + found++; + if (pd.product.promo) { + promoSaved += pd.product.promo.discount; + } + } + } + + if (found === 0) { + banner.style.display = 'none'; + return; + } + + banner.style.display = 'block'; + valueEl.textContent = `€ ${total.toFixed(2)}`; + + let detail = `${found}/${shoppingItems.length} prodotti trovati`; + if (promoSaved > 0) { + detail += ` · 🏷️ Risparmi €${promoSaved.toFixed(2)} con le offerte`; + } + detailEl.textContent = detail; +} + +async function searchItemPrice(idx, force = false) { + const item = shoppingItems[idx]; + if (!item) return; + + const priceKey = item.name.toLowerCase(); + const cached = shoppingPrices[priceKey]; + // Invalidate cache if spec changed (e.g. item was updated in Bring) + if (!force && cached && cached.searched) { + const cachedSpec = (cached.spec || '').toLowerCase(); + const currentSpec = (item.specification || '').toLowerCase(); + if (cachedSpec === currentSpec) return; + } + + const s = getSettings(); + const provider = s.spesa_provider || 'dupliclick'; + + // Show loading state + shoppingPrices[priceKey] = { searched: false, loading: true, product: null }; + renderShoppingItems(); + + try { + // Include specification in the search query for better catalog results + let searchQ = item.name; + const spec = item.specification || ''; + // Strip priority emojis from spec before appending + const cleanSpec = spec.replace(/[🔴🟡🟢]/g, '').trim(); + if (cleanSpec) searchQ += ' ' + cleanSpec; + + const s2 = getSettings(); + const aiPrompt = s2.spesa_ai_prompt || ''; + const res = await api(`${provider}_search`, { + q: searchQ, + spec: spec, + prompt: aiPrompt + }); + if (res.success && res.product) { + shoppingPrices[priceKey] = { searched: true, product: res.product, spec: item.specification || '' }; + } else { + shoppingPrices[priceKey] = { searched: true, product: null }; + } + } catch (e) { + shoppingPrices[priceKey] = { searched: true, product: null }; + } + + saveShoppingPrices(); + renderShoppingItems(); +} + +async function searchAllPrices() { + const s = getSettings(); + if (!s.spesa_logged_in && !s.spesa_token) { + // Try server-side check + try { + const status = await api('dupliclick_status'); + if (!status.logged_in) { + showToast('Configura prima la Spesa Online nelle impostazioni', 'error'); + return; + } + s.spesa_logged_in = true; + s.spesa_token = 'server'; + saveSettings(s); + } catch (e) { + showToast('Configura prima la Spesa Online nelle impostazioni', 'error'); + return; + } + } + + const btn = document.getElementById('btn-search-prices'); + const toSearch = shoppingItems.filter(item => { + const pd = shoppingPrices[item.name.toLowerCase()]; + return !pd || !pd.searched; + }); + + if (toSearch.length === 0) { + showToast('Tutti i prodotti sono già stati cercati. Usa 🔄 per ricercare singoli.', 'info'); + return; + } + + btn.disabled = true; + const totalToSearch = toSearch.length; + + for (let i = 0; i < toSearch.length; i++) { + const item = toSearch[i]; + btn.innerHTML = `⏳ Cerco ${i + 1}/${totalToSearch}...`; + + const priceKey = item.name.toLowerCase(); + const provider = s.spesa_provider || 'dupliclick'; + + try { + const aiPrompt = s.spesa_ai_prompt || ''; + const res = await api(`${provider}_search`, { + q: item.name, + spec: item.specification || '', + prompt: aiPrompt + }); + if (res.success && res.product) { + shoppingPrices[priceKey] = { searched: true, product: res.product, spec: item.specification || '' }; + } else { + shoppingPrices[priceKey] = { searched: true, product: null }; + } + } catch (e) { + shoppingPrices[priceKey] = { searched: true, product: null }; + } + + saveShoppingPrices(); + renderShoppingItems(); + + // Small delay to not overwhelm the API + if (i < toSearch.length - 1) { + await new Promise(r => setTimeout(r, 300)); + } + } + + btn.disabled = false; + btn.innerHTML = '🔍 Cerca tutti i prezzi'; + showToast(`Ricerca completata: ${totalToSearch} prodotti`, 'success'); } async function removeBringItem(idx) { @@ -3062,7 +3394,9 @@ async function addSelectedSuggestions() { const data = await api('bring_add', {}, 'POST', { items, listUUID: shoppingListUUID }); if (data.success) { - showToast(`${data.added} prodott${data.added === 1 ? 'o aggiunto' : 'i aggiunti'} a Bring!`, 'success'); + let msg = `${data.added} prodott${data.added === 1 ? 'o aggiunto' : 'i aggiunti'} a Bring!`; + if (data.skipped > 0) msg += ` (${data.skipped} già in lista)`; + showToast(msg, 'success'); // Refresh list await loadShoppingList(); // Update dashboard shopping count @@ -3361,6 +3695,82 @@ const MEAL_LABELS = { 'cena': '🌙 Cena' }; +// ===== RECIPE ARCHIVE ===== +function getRecipeArchive() { + try { + return JSON.parse(localStorage.getItem('dispensa_recipe_archive') || '[]'); + } catch { return []; } +} + +function saveRecipeToArchive(recipe) { + const archive = getRecipeArchive(); + const today = new Date().toISOString().slice(0, 10); + archive.unshift({ date: today, meal: recipe.meal, recipe, savedAt: Date.now() }); + // Keep max 60 recipes + if (archive.length > 60) archive.length = 60; + localStorage.setItem('dispensa_recipe_archive', JSON.stringify(archive)); +} + +function loadRecipeArchive() { + const container = document.getElementById('recipe-archive'); + if (!container) return; + const archive = getRecipeArchive(); + + if (archive.length === 0) { + container.innerHTML = '
🍳

Nessuna ricetta salvata.
Genera la tua prima ricetta!

'; + return; + } + + // Group by date + const byDate = {}; + for (const entry of archive) { + if (!byDate[entry.date]) byDate[entry.date] = []; + byDate[entry.date].push(entry); + } + + let html = ''; + const today = new Date().toISOString().slice(0, 10); + const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); + + for (const [date, entries] of Object.entries(byDate)) { + let dateLabel = new Date(date + 'T12:00:00').toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long' }); + if (date === today) dateLabel = '📅 Oggi'; + else if (date === yesterday) dateLabel = '📅 Ieri'; + + html += `
`; + html += `
${escapeHtml(dateLabel)}
`; + + for (const entry of entries) { + const r = entry.recipe; + const mealIcon = MEAL_LABELS[r.meal] || r.meal; + const tags = (r.tags || []).slice(0, 3).join(', '); + html += `
`; + html += `
`; + html += `${mealIcon}`; + html += `${escapeHtml(r.title)}`; + html += `
`; + html += `
`; + if (r.prep_time) html += `🔪 ${r.prep_time}`; + if (r.cook_time) html += `🔥 ${r.cook_time}`; + html += `👥 ${r.persons}`; + if (tags) html += `${tags}`; + html += `
`; + } + html += `
`; + } + + container.innerHTML = html; +} + +function viewArchivedRecipe(entryJson) { + const entry = JSON.parse(entryJson); + renderRecipe(entry.recipe); + document.getElementById('recipe-overlay').style.display = 'flex'; + document.getElementById('recipe-ask').style.display = 'none'; + document.getElementById('recipe-loading').style.display = 'none'; + document.getElementById('recipe-result').style.display = ''; +} + function openRecipeDialog() { const meal = getMealType(); const settings = getSettings(); @@ -3592,6 +4002,9 @@ async function generateRecipe() { const r = result.recipe; renderRecipe(r); + // Save to archive + saveRecipeToArchive(r); + // Cache the recipe for this meal type localStorage.setItem('cachedRecipe', JSON.stringify({ meal, recipe: r })); @@ -3781,3 +4194,134 @@ function saveChatHistory() { document.addEventListener('DOMContentLoaded', () => { showPage('dashboard'); }); + +// ===== DUPLICLICK (SPESA ONLINE) ===== + +function selectSpesaProvider(btn, provider) { + document.querySelectorAll('.provider-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const s = getSettings(); + s.spesa_provider = provider; + saveSettingsToStorage(s); +} + +async function spesaLogin() { + const email = document.getElementById('setting-spesa-email').value.trim(); + const password = document.getElementById('setting-spesa-password').value.trim(); + const s = getSettings(); + const provider = s.spesa_provider || 'dupliclick'; + + if (!email || !password) { + showToast('Inserisci email e password', 'error'); + return; + } + + const btn = document.getElementById('spesa-login-btn'); + const statusEl = document.getElementById('spesa-login-status'); + const resultEl = document.getElementById('spesa-login-result'); + + btn.disabled = true; + btn.innerHTML = '⏳ Accesso in corso...'; + statusEl.style.display = 'none'; + resultEl.style.display = 'none'; + + try { + const res = await api(`${provider}_login`, {}, 'POST', { email, password }); + + if (res.error) { + statusEl.className = 'dupliclick-status error'; + statusEl.innerHTML = `❌ Errore: ${escapeHtml(res.error)}`; + statusEl.style.display = 'block'; + btn.disabled = false; + btn.innerHTML = '🔐 Accedi'; + return; + } + + // Save credentials and session data persistently + s.spesa_email = email; + s.spesa_password = password; + s.spesa_token = res.token_full || ''; + s.spesa_provider = provider; + s.spesa_logged_in = true; + s.spesa_user = res.user || (res.data && res.data.user) || {}; + s.spesa_data = res.data || {}; + // Save AI prompt too + const promptEl = document.getElementById('setting-spesa-ai-prompt'); + if (promptEl) s.spesa_ai_prompt = promptEl.value.trim(); + saveSettingsToStorage(s); + + statusEl.className = 'dupliclick-status success'; + const welcomeMsg = (res.infos && res.infos[0]) ? res.infos[0].info : 'Login effettuato!'; + statusEl.innerHTML = `✅ ${escapeHtml(welcomeMsg)}`; + statusEl.style.display = 'block'; + + // Display key info only + const user = res.user || (res.data && res.data.user) || {}; + const data = res.data || {}; + const shipping = data.shippingAddress || {}; + const points = user.userPoints || data.userPoints || {}; + const fidelityPts = Array.isArray(points) ? points[0] : points['0']; + + let html = '
'; + html += '
'; + + if (user.firstName) html += `
👤 Nome${escapeHtml(user.firstName)} ${escapeHtml(user.lastName || '')}
`; + if (user.fidelityCard) html += `
💳 Tessera${escapeHtml(user.fidelityCard)}
`; + if (shipping.addressName) html += `
🏪 Punto Ritiro${escapeHtml(shipping.addressName)}
`; + if (fidelityPts) html += `
⭐ Punti Fedeltà${fidelityPts.value || 0}
`; + + html += '
'; + resultEl.innerHTML = html; + resultEl.style.display = 'block'; + + } catch (e) { + statusEl.className = 'dupliclick-status error'; + statusEl.innerHTML = `❌ Errore di rete: ${escapeHtml(e.message)}`; + statusEl.style.display = 'block'; + } + + btn.disabled = false; + btn.innerHTML = '🔐 Accedi'; +} + +function loadSpesaSettings() { + const s = getSettings(); + const emailEl = document.getElementById('setting-spesa-email'); + const passEl = document.getElementById('setting-spesa-password'); + const promptEl = document.getElementById('setting-spesa-ai-prompt'); + if (emailEl) emailEl.value = s.spesa_email || s.dupliclick_email || ''; + if (passEl) passEl.value = s.spesa_password || s.dupliclick_password || ''; + if (promptEl) promptEl.value = s.spesa_ai_prompt || DEFAULT_SPESA_AI_PROMPT; + + // Show saved login state + if (s.spesa_logged_in && s.spesa_user) { + const statusEl = document.getElementById('spesa-login-status'); + const resultEl = document.getElementById('spesa-login-result'); + const loginBtn = document.getElementById('spesa-login-btn'); + + if (loginBtn) { + loginBtn.innerHTML = '✅ Connesso — Riaccedi'; + loginBtn.className = 'btn btn-large btn-secondary full-width mt-2'; + } + if (statusEl) { + statusEl.className = 'dupliclick-status success'; + statusEl.innerHTML = `✅ Connesso come ${escapeHtml(s.spesa_user.firstName || '')} ${escapeHtml(s.spesa_user.lastName || '')}`; + statusEl.style.display = 'block'; + } + if (resultEl) { + const user = s.spesa_user; + const shipping = (s.spesa_data && s.spesa_data.shippingAddress) || {}; + const points = user.userPoints || (s.spesa_data && s.spesa_data.userPoints) || {}; + const fidelityPts = Array.isArray(points) ? points[0] : points['0']; + + let html = '
'; + if (user.firstName) html += `
👤 Nome${escapeHtml(user.firstName)} ${escapeHtml(user.lastName || '')}
`; + if (user.fidelityCard) html += `
💳 Tessera${escapeHtml(user.fidelityCard)}
`; + if (shipping.addressName) html += `
🏪 Punto Ritiro${escapeHtml(shipping.addressName)}
`; + if (fidelityPts) html += `
⭐ Punti Fedeltà${fidelityPts.value || 0}
`; + html += '
'; + resultEl.innerHTML = html; + resultEl.style.display = 'block'; + } + } +} diff --git a/index.html b/index.html index e9b398f..8d94d19 100644 --- a/index.html +++ b/index.html @@ -6,10 +6,10 @@ - 🏠 Dispensa Manager + Dispensa Manager - + @@ -446,6 +446,20 @@
+ +
+ +
+ +
+
+
+
+ +
+
+

🛍️ Spesa Online

+

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.

+
+
+
+
+ +
+
+

🔒 Certificato HTTPS

+

Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.

+ +
+ Istruzioni per Chrome (Android):
+ 1. Scarica il certificato qui sopra
+ 2. Vai in Impostazioni → Sicurezza e privacy → Altre impostazioni di sicurezza → Installa da archivio dispositivo
+ 3. Seleziona il file Dispensa_CA.crt scaricato
+ 4. Scegli "CA" e conferma
+ 5. Riavvia Chrome

+ Istruzioni per Chrome (PC):
+ 1. Scarica il certificato qui sopra
+ 2. Vai in chrome://settings/certificates (o Impostazioni → Privacy e sicurezza → Sicurezza → Gestisci certificati)
+ 3. Tab "Autorità" → Importa → seleziona il file
+ 4. Spunta "Considera attendibile per identificare siti web"
+ 5. Riavvia Chrome +
+
+
@@ -670,9 +759,9 @@ 📋 Dispensa -