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
This commit is contained in:
+377
-8
@@ -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;
|
||||
}
|
||||
|
||||
+380
-5
@@ -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;
|
||||
}
|
||||
|
||||
+551
-7
@@ -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 = '<div class="empty-state" style="padding:20px"><div class="empty-state-icon">✅</div><p>Lista della spesa vuota!<br>Usa il pulsante sotto per generare suggerimenti.</p></div>';
|
||||
updateSpesaTotal();
|
||||
return;
|
||||
}
|
||||
|
||||
const s = getSettings();
|
||||
let hasSpesa = s.spesa_logged_in && s.spesa_token;
|
||||
|
||||
// If not logged in locally, check server-side token
|
||||
if (!hasSpesa) {
|
||||
try {
|
||||
const status = await api('dupliclick_status');
|
||||
if (status.logged_in) {
|
||||
hasSpesa = true;
|
||||
s.spesa_logged_in = true;
|
||||
s.spesa_token = 'server';
|
||||
s.spesa_user = status.email || '';
|
||||
saveSettings(s);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
container.innerHTML = shoppingItems.map((item, idx) => {
|
||||
const catIcon = CATEGORY_ICONS[guessCategoryFromName(item.name)] || '🛒';
|
||||
const priceKey = item.name.toLowerCase();
|
||||
const priceData = shoppingPrices[priceKey];
|
||||
|
||||
let detailHtml = '';
|
||||
let priceTag = '';
|
||||
let spesaBar = '';
|
||||
if (hasSpesa) {
|
||||
if (priceData && priceData.loading) {
|
||||
detailHtml = `<div class="spesa-loading">🔍 Cerco...</div>`;
|
||||
} else if (priceData && priceData.product) {
|
||||
const p = priceData.product;
|
||||
const promoHtml = p.promo
|
||||
? `<span class="spesa-promo-badge">${escapeHtml(p.promo.label)} -${Math.round(p.promo.discountPerc)}%</span>`
|
||||
: '';
|
||||
const est = estimateItemPrice(p, item.specification || priceData.spec || '');
|
||||
if (est) {
|
||||
priceTag = `<div class="shopping-item-price">~€${est.estimated.toFixed(2)}</div>`;
|
||||
} else {
|
||||
priceTag = `<div class="shopping-item-price">€${p.price.toFixed(2)}</div>`;
|
||||
}
|
||||
detailHtml = `<div class="spesa-detail-left">
|
||||
<span class="spesa-found-name">${escapeHtml(p.name)}</span>
|
||||
<span class="spesa-pkg">${escapeHtml(p.packageDescr)}${est ? ' · ' + escapeHtml(String(p.priceUm || '')) + '/kg' : ''}</span>
|
||||
${promoHtml}
|
||||
</div>`;
|
||||
spesaBar = `<div class="spesa-bar">
|
||||
<button class="spesa-bar-btn" onclick="searchItemPrice(${idx}, true)" title="Ricerca">🔄 Ricerca</button>
|
||||
<a href="${escapeHtml(p.url)}" target="_blank" class="spesa-bar-btn" title="${escapeHtml(p.name)} - ${escapeHtml(p.brand)}">🔗 Apri</a>
|
||||
</div>`;
|
||||
} else if (priceData && priceData.searched && !priceData.product) {
|
||||
detailHtml = `<div class="spesa-detail-left"><span class="spesa-not-found">Non trovato</span></div>`;
|
||||
spesaBar = `<div class="spesa-bar">
|
||||
<button class="spesa-bar-btn" onclick="searchItemPrice(${idx}, true)" title="Riprova">🔄 Riprova</button>
|
||||
</div>`;
|
||||
} else {
|
||||
spesaBar = `<div class="spesa-bar">
|
||||
<button class="spesa-bar-btn" onclick="searchItemPrice(${idx})" title="Cerca prezzo">🔍 Cerca prezzo</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="shopping-item">
|
||||
<div class="shopping-item ${priceData && priceData.product && priceData.product.promo ? 'has-promo' : ''}" id="shop-item-${idx}">
|
||||
<span class="shopping-item-icon">${catIcon}</span>
|
||||
<div class="shopping-item-info">
|
||||
<div class="shopping-item-name">${escapeHtml(item.name)}</div>
|
||||
${item.specification ? `<div class="shopping-item-spec">${escapeHtml(item.specification)}</div>` : ''}
|
||||
<div class="shopping-item-body">
|
||||
<div class="shopping-item-top">
|
||||
<div class="shopping-item-info">
|
||||
<div class="shopping-item-name">${escapeHtml(item.name)}</div>
|
||||
${item.specification ? `<div class="shopping-item-spec">${escapeHtml(item.specification)}</div>` : ''}
|
||||
${detailHtml}
|
||||
</div>
|
||||
<div class="shopping-item-right">
|
||||
${priceTag}
|
||||
<button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="Rimuovi">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
${spesaBar}
|
||||
</div>
|
||||
<button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="Rimuovi">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
updateSpesaTotal();
|
||||
}
|
||||
|
||||
function updateSpesaTotal() {
|
||||
const banner = document.getElementById('spesa-total-banner');
|
||||
const valueEl = document.getElementById('spesa-total-value');
|
||||
const detailEl = document.getElementById('spesa-total-detail');
|
||||
|
||||
let total = 0;
|
||||
let found = 0;
|
||||
let promoSaved = 0;
|
||||
|
||||
for (const item of shoppingItems) {
|
||||
const pd = shoppingPrices[item.name.toLowerCase()];
|
||||
if (pd && pd.product) {
|
||||
const est = estimateItemPrice(pd.product, item.specification || pd.spec || '');
|
||||
total += est ? est.estimated : pd.product.price;
|
||||
found++;
|
||||
if (pd.product.promo) {
|
||||
promoSaved += pd.product.promo.discount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found === 0) {
|
||||
banner.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
banner.style.display = 'block';
|
||||
valueEl.textContent = `€ ${total.toFixed(2)}`;
|
||||
|
||||
let detail = `${found}/${shoppingItems.length} prodotti trovati`;
|
||||
if (promoSaved > 0) {
|
||||
detail += ` · 🏷️ Risparmi €${promoSaved.toFixed(2)} con le offerte`;
|
||||
}
|
||||
detailEl.textContent = detail;
|
||||
}
|
||||
|
||||
async function searchItemPrice(idx, force = false) {
|
||||
const item = shoppingItems[idx];
|
||||
if (!item) return;
|
||||
|
||||
const priceKey = item.name.toLowerCase();
|
||||
const cached = shoppingPrices[priceKey];
|
||||
// Invalidate cache if spec changed (e.g. item was updated in Bring)
|
||||
if (!force && cached && cached.searched) {
|
||||
const cachedSpec = (cached.spec || '').toLowerCase();
|
||||
const currentSpec = (item.specification || '').toLowerCase();
|
||||
if (cachedSpec === currentSpec) return;
|
||||
}
|
||||
|
||||
const s = getSettings();
|
||||
const provider = s.spesa_provider || 'dupliclick';
|
||||
|
||||
// Show loading state
|
||||
shoppingPrices[priceKey] = { searched: false, loading: true, product: null };
|
||||
renderShoppingItems();
|
||||
|
||||
try {
|
||||
// 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 = '<div class="empty-state" style="padding:20px"><div class="empty-state-icon">🍳</div><p>Nessuna ricetta salvata.<br>Genera la tua prima ricetta!</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by date
|
||||
const byDate = {};
|
||||
for (const entry of archive) {
|
||||
if (!byDate[entry.date]) byDate[entry.date] = [];
|
||||
byDate[entry.date].push(entry);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
|
||||
for (const [date, entries] of Object.entries(byDate)) {
|
||||
let dateLabel = new Date(date + 'T12:00:00').toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
if (date === today) dateLabel = '📅 Oggi';
|
||||
else if (date === yesterday) dateLabel = '📅 Ieri';
|
||||
|
||||
html += `<div class="recipe-archive-day">`;
|
||||
html += `<div class="recipe-archive-date">${escapeHtml(dateLabel)}</div>`;
|
||||
|
||||
for (const entry of entries) {
|
||||
const r = entry.recipe;
|
||||
const mealIcon = MEAL_LABELS[r.meal] || r.meal;
|
||||
const tags = (r.tags || []).slice(0, 3).join(', ');
|
||||
html += `<div class="recipe-archive-card" onclick='viewArchivedRecipe(${JSON.stringify(JSON.stringify(entry))})'>`;
|
||||
html += `<div class="recipe-archive-card-header">`;
|
||||
html += `<span class="recipe-archive-meal">${mealIcon}</span>`;
|
||||
html += `<span class="recipe-archive-title">${escapeHtml(r.title)}</span>`;
|
||||
html += `</div>`;
|
||||
html += `<div class="recipe-archive-card-meta">`;
|
||||
if (r.prep_time) html += `<span>🔪 ${r.prep_time}</span>`;
|
||||
if (r.cook_time) html += `<span>🔥 ${r.cook_time}</span>`;
|
||||
html += `<span>👥 ${r.persons}</span>`;
|
||||
if (tags) html += `<span>${tags}</span>`;
|
||||
html += `</div></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
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 = `❌ <strong>Errore:</strong> ${escapeHtml(res.error)}`;
|
||||
statusEl.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '🔐 Accedi';
|
||||
return;
|
||||
}
|
||||
|
||||
// Save credentials and session data persistently
|
||||
s.spesa_email = email;
|
||||
s.spesa_password = password;
|
||||
s.spesa_token = res.token_full || '';
|
||||
s.spesa_provider = provider;
|
||||
s.spesa_logged_in = true;
|
||||
s.spesa_user = res.user || (res.data && res.data.user) || {};
|
||||
s.spesa_data = res.data || {};
|
||||
// Save AI prompt too
|
||||
const promptEl = document.getElementById('setting-spesa-ai-prompt');
|
||||
if (promptEl) s.spesa_ai_prompt = promptEl.value.trim();
|
||||
saveSettingsToStorage(s);
|
||||
|
||||
statusEl.className = 'dupliclick-status success';
|
||||
const welcomeMsg = (res.infos && res.infos[0]) ? res.infos[0].info : 'Login effettuato!';
|
||||
statusEl.innerHTML = `✅ <strong>${escapeHtml(welcomeMsg)}</strong>`;
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
// Display key info only
|
||||
const user = res.user || (res.data && res.data.user) || {};
|
||||
const data = res.data || {};
|
||||
const shipping = data.shippingAddress || {};
|
||||
const points = user.userPoints || data.userPoints || {};
|
||||
const fidelityPts = Array.isArray(points) ? points[0] : points['0'];
|
||||
|
||||
let html = '<div class="dupliclick-data">';
|
||||
html += '<div class="dupliclick-data-grid">';
|
||||
|
||||
if (user.firstName) html += `<div class="data-row"><span class="data-label">👤 Nome</span><span class="data-value">${escapeHtml(user.firstName)} ${escapeHtml(user.lastName || '')}</span></div>`;
|
||||
if (user.fidelityCard) html += `<div class="data-row"><span class="data-label">💳 Tessera</span><span class="data-value">${escapeHtml(user.fidelityCard)}</span></div>`;
|
||||
if (shipping.addressName) html += `<div class="data-row"><span class="data-label">🏪 Punto Ritiro</span><span class="data-value">${escapeHtml(shipping.addressName)}</span></div>`;
|
||||
if (fidelityPts) html += `<div class="data-row"><span class="data-label">⭐ Punti Fedeltà</span><span class="data-value">${fidelityPts.value || 0}</span></div>`;
|
||||
|
||||
html += '</div></div>';
|
||||
resultEl.innerHTML = html;
|
||||
resultEl.style.display = 'block';
|
||||
|
||||
} catch (e) {
|
||||
statusEl.className = 'dupliclick-status error';
|
||||
statusEl.innerHTML = `❌ <strong>Errore di rete:</strong> ${escapeHtml(e.message)}`;
|
||||
statusEl.style.display = 'block';
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '🔐 Accedi';
|
||||
}
|
||||
|
||||
function loadSpesaSettings() {
|
||||
const s = getSettings();
|
||||
const emailEl = document.getElementById('setting-spesa-email');
|
||||
const passEl = document.getElementById('setting-spesa-password');
|
||||
const promptEl = document.getElementById('setting-spesa-ai-prompt');
|
||||
if (emailEl) emailEl.value = s.spesa_email || s.dupliclick_email || '';
|
||||
if (passEl) passEl.value = s.spesa_password || s.dupliclick_password || '';
|
||||
if (promptEl) promptEl.value = s.spesa_ai_prompt || DEFAULT_SPESA_AI_PROMPT;
|
||||
|
||||
// Show saved login state
|
||||
if (s.spesa_logged_in && s.spesa_user) {
|
||||
const statusEl = document.getElementById('spesa-login-status');
|
||||
const resultEl = document.getElementById('spesa-login-result');
|
||||
const loginBtn = document.getElementById('spesa-login-btn');
|
||||
|
||||
if (loginBtn) {
|
||||
loginBtn.innerHTML = '✅ Connesso — Riaccedi';
|
||||
loginBtn.className = 'btn btn-large btn-secondary full-width mt-2';
|
||||
}
|
||||
if (statusEl) {
|
||||
statusEl.className = 'dupliclick-status success';
|
||||
statusEl.innerHTML = `✅ <strong>Connesso come ${escapeHtml(s.spesa_user.firstName || '')} ${escapeHtml(s.spesa_user.lastName || '')}</strong>`;
|
||||
statusEl.style.display = 'block';
|
||||
}
|
||||
if (resultEl) {
|
||||
const user = s.spesa_user;
|
||||
const shipping = (s.spesa_data && s.spesa_data.shippingAddress) || {};
|
||||
const points = user.userPoints || (s.spesa_data && s.spesa_data.userPoints) || {};
|
||||
const fidelityPts = Array.isArray(points) ? points[0] : points['0'];
|
||||
|
||||
let html = '<div class="dupliclick-data"><div class="dupliclick-data-grid">';
|
||||
if (user.firstName) html += `<div class="data-row"><span class="data-label">👤 Nome</span><span class="data-value">${escapeHtml(user.firstName)} ${escapeHtml(user.lastName || '')}</span></div>`;
|
||||
if (user.fidelityCard) html += `<div class="data-row"><span class="data-label">💳 Tessera</span><span class="data-value">${escapeHtml(user.fidelityCard)}</span></div>`;
|
||||
if (shipping.addressName) html += `<div class="data-row"><span class="data-label">🏪 Punto Ritiro</span><span class="data-value">${escapeHtml(shipping.addressName)}</span></div>`;
|
||||
if (fidelityPts) html += `<div class="data-row"><span class="data-label">⭐ Punti Fedeltà</span><span class="data-value">${fidelityPts.value || 0}</span></div>`;
|
||||
html += '</div></div>';
|
||||
resultEl.innerHTML = html;
|
||||
resultEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+98
-9
@@ -6,10 +6,10 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="theme-color" content="#2d5016">
|
||||
<title>🏠 Dispensa Manager</title>
|
||||
<title>Dispensa Manager</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260311c">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260312h">
|
||||
<!-- QuaggaJS for barcode scanning -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||
</head>
|
||||
@@ -446,6 +446,20 @@
|
||||
<div class="products-list" id="products-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- ===== RECIPE PAGE ===== -->
|
||||
<section class="page" id="page-recipe">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button>
|
||||
<h2>🍳 Ricette</h2>
|
||||
</div>
|
||||
<div class="recipe-page-container">
|
||||
<button class="btn btn-large btn-success full-width" onclick="openRecipeDialog()">
|
||||
✨ Genera nuova ricetta
|
||||
</button>
|
||||
<div id="recipe-archive" class="recipe-archive"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
||||
<section class="page" id="page-shopping">
|
||||
<div class="page-header">
|
||||
@@ -456,6 +470,14 @@
|
||||
<div class="bring-status" id="bring-status">
|
||||
<div class="bring-loading">Connessione a Bring!...</div>
|
||||
</div>
|
||||
<!-- Price total banner -->
|
||||
<div class="spesa-total-banner" id="spesa-total-banner" style="display:none">
|
||||
<div class="spesa-total-row">
|
||||
<span class="spesa-total-label">💰 Totale stimato</span>
|
||||
<span class="spesa-total-value" id="spesa-total-value">€ 0,00</span>
|
||||
</div>
|
||||
<div class="spesa-total-detail" id="spesa-total-detail"></div>
|
||||
</div>
|
||||
<div class="shopping-current" id="shopping-current" style="display:none">
|
||||
<div class="shopping-section-header">
|
||||
<h3>🛍️ Da comprare</h3>
|
||||
@@ -476,6 +498,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="shopping-actions">
|
||||
<button class="btn btn-large btn-accent" onclick="searchAllPrices()" id="btn-search-prices">
|
||||
🔍 Cerca tutti i prezzi
|
||||
</button>
|
||||
<button class="btn btn-large btn-accent" onclick="generateSuggestions()" id="btn-suggest">
|
||||
🤖 Suggerisci cosa comprare
|
||||
</button>
|
||||
@@ -528,10 +553,12 @@
|
||||
<h2>⚙️ Configurazione</h2>
|
||||
</div>
|
||||
<div class="settings-tabs">
|
||||
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api">🔑 API</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring">🛒 Bring!</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe">🍳 Ricette</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances">🔌 Elettrodomestici</button>
|
||||
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" title="Bring!">🛒</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-spesa')" data-tab="tab-spesa" title="Spesa Online">🛍️</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
||||
</div>
|
||||
<div class="settings-panels">
|
||||
<!-- API Keys Tab -->
|
||||
@@ -621,6 +648,68 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Spesa Online Tab -->
|
||||
<div class="settings-panel" id="tab-spesa">
|
||||
<div class="settings-card">
|
||||
<h4>🛍️ Spesa Online</h4>
|
||||
<p class="settings-hint">Configura il provider per la spesa online.</p>
|
||||
<div class="form-group">
|
||||
<label>🏪 Provider</label>
|
||||
<div class="provider-selector">
|
||||
<button class="provider-btn active" onclick="selectSpesaProvider(this, 'dupliclick')">
|
||||
<span class="provider-icon">🛒</span>
|
||||
<span class="provider-name">DupliClick</span>
|
||||
<span class="provider-desc">Gruppo Poli</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spesa-provider-config">
|
||||
<div class="form-group">
|
||||
<label>📧 Email</label>
|
||||
<input type="email" id="setting-spesa-email" class="form-input" placeholder="email@esempio.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>🔒 Password</label>
|
||||
<input type="password" id="setting-spesa-password" class="form-input" placeholder="Password">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-spesa-password')">👁️ Mostra/Nascondi</button>
|
||||
</div>
|
||||
<button class="btn btn-large btn-accent full-width mt-2" id="spesa-login-btn" onclick="spesaLogin()">
|
||||
🔐 Accedi
|
||||
</button>
|
||||
<div id="spesa-login-status" style="display:none"></div>
|
||||
<div id="spesa-login-result" style="display:none"></div>
|
||||
<div class="form-group mt-2">
|
||||
<label>🤖 Prompt AI selezione prodotto</label>
|
||||
<textarea id="setting-spesa-ai-prompt" class="form-input" rows="4" placeholder="Istruzioni per l'AI quando deve scegliere tra più prodotti..."></textarea>
|
||||
<p class="settings-hint">L'AI usa questo prompt per scegliere il prodotto più appropriato tra i risultati. Lascia vuoto per il comportamento predefinito.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Security Tab -->
|
||||
<div class="settings-panel" id="tab-security">
|
||||
<div class="settings-card">
|
||||
<h4>🔒 Certificato HTTPS</h4>
|
||||
<p class="settings-hint">Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.</p>
|
||||
<div class="form-group">
|
||||
<a href="ca.crt" download="Dispensa_CA.crt" class="btn btn-large btn-accent full-width" style="text-align:center;text-decoration:none;display:block">📥 Scarica Certificato CA</a>
|
||||
</div>
|
||||
<div class="settings-hint" style="margin-top:12px;line-height:1.6">
|
||||
<strong>Istruzioni per Chrome (Android):</strong><br>
|
||||
1. Scarica il certificato qui sopra<br>
|
||||
2. Vai in <em>Impostazioni → Sicurezza e privacy → Altre impostazioni di sicurezza → Installa da archivio dispositivo</em><br>
|
||||
3. Seleziona il file <em>Dispensa_CA.crt</em> scaricato<br>
|
||||
4. Scegli "CA" e conferma<br>
|
||||
5. Riavvia Chrome<br><br>
|
||||
<strong>Istruzioni per Chrome (PC):</strong><br>
|
||||
1. Scarica il certificato qui sopra<br>
|
||||
2. Vai in <em>chrome://settings/certificates</em> (o Impostazioni → Privacy e sicurezza → Sicurezza → Gestisci certificati)<br>
|
||||
3. Tab "Autorità" → Importa → seleziona il file<br>
|
||||
4. Spunta "Considera attendibile per identificare siti web"<br>
|
||||
5. Riavvia Chrome
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-large btn-success full-width mt-2" onclick="saveSettings()">💾 Salva Configurazione</button>
|
||||
<div id="settings-status" class="settings-status" style="display:none"></div>
|
||||
@@ -670,9 +759,9 @@
|
||||
<span class="nav-icon">📋</span>
|
||||
<span class="nav-label">Dispensa</span>
|
||||
</button>
|
||||
<button class="nav-btn" onclick="openRecipeDialog()" data-page="recipe">
|
||||
<button class="nav-btn" onclick="showPage('recipe')" data-page="recipe">
|
||||
<span class="nav-icon">🍳</span>
|
||||
<span class="nav-label">Ricetta</span>
|
||||
<span class="nav-label">Ricette</span>
|
||||
</button>
|
||||
<button class="nav-btn" onclick="showPage('shopping')" data-page="shopping">
|
||||
<span class="nav-icon">🛒</span>
|
||||
@@ -750,6 +839,6 @@
|
||||
<div class="modal-content" id="modal-content" onclick="event.stopPropagation()"></div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260311c"></script>
|
||||
<script src="assets/js/app.js?v=20260312n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user