Remove all Dupliclick/Spesa integration; merge annual waste info into status line
This commit is contained in:
@@ -7,7 +7,6 @@ data/cron.log
|
|||||||
data/smart_shopping_cache.json
|
data/smart_shopping_cache.json
|
||||||
data/bring_token.json
|
data/bring_token.json
|
||||||
data/bring_catalog.json
|
data/bring_catalog.json
|
||||||
data/dupliclick_token.json
|
|
||||||
data/client_debug.log
|
data/client_debug.log
|
||||||
data/*.crt
|
data/*.crt
|
||||||
data/*.pem
|
data/*.pem
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ data/cron.log
|
|||||||
data/smart_shopping_cache.json
|
data/smart_shopping_cache.json
|
||||||
data/bring_token.json
|
data/bring_token.json
|
||||||
data/bring_catalog.json
|
data/bring_catalog.json
|
||||||
data/dupliclick_token.json
|
|
||||||
data/client_debug.log
|
data/client_debug.log
|
||||||
data/rate_limits/
|
data/rate_limits/
|
||||||
|
|
||||||
|
|||||||
+2
-436
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* EverShelf - Main API Router
|
* EverShelf - Main API Router
|
||||||
* Handles all CRUD operations for products, inventory, shopping lists,
|
* Handles all CRUD operations for products, inventory, shopping lists,
|
||||||
* AI-powered features (Gemini), and third-party integrations (Bring!, DupliClick).
|
* AI-powered features (Gemini), and third-party integrations (Bring!).
|
||||||
*
|
*
|
||||||
* @author Stimpfl Daniel <evershelfproject@gmail.com>
|
* @author Stimpfl Daniel <evershelfproject@gmail.com>
|
||||||
* @license MIT
|
* @license MIT
|
||||||
@@ -65,7 +65,7 @@ function checkRateLimit(string $action): void {
|
|||||||
|
|
||||||
// Determine limit based on action
|
// Determine limit based on action
|
||||||
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping'];
|
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping'];
|
||||||
$loginActions = ['dupliclick_login'];
|
$loginActions = [];
|
||||||
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
||||||
|
|
||||||
if (in_array($action, $aiActions)) {
|
if (in_array($action, $aiActions)) {
|
||||||
@@ -284,25 +284,6 @@ try {
|
|||||||
migrateUnitsToBase($db);
|
migrateUnitsToBase($db);
|
||||||
break;
|
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;
|
|
||||||
|
|
||||||
// ===== SHARED APP DATA =====
|
// ===== SHARED APP DATA =====
|
||||||
case 'app_settings_get':
|
case 'app_settings_get':
|
||||||
appSettingsGet($db);
|
appSettingsGet($db);
|
||||||
@@ -1963,8 +1944,6 @@ function getServerSettings(): void {
|
|||||||
'camera_facing' => env('CAMERA_FACING', 'environment'),
|
'camera_facing' => env('CAMERA_FACING', 'environment'),
|
||||||
'scale_enabled' => env('SCALE_ENABLED', 'false') === 'true',
|
'scale_enabled' => env('SCALE_ENABLED', 'false') === 'true',
|
||||||
'scale_gateway_url' => env('SCALE_GATEWAY_URL', ''),
|
'scale_gateway_url' => env('SCALE_GATEWAY_URL', ''),
|
||||||
'spesa_provider' => env('SPESA_PROVIDER', 'bring'),
|
|
||||||
'spesa_ai_prompt' => env('SPESA_AI_PROMPT', ''),
|
|
||||||
'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true',
|
'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -1988,8 +1967,6 @@ function saveSettings(): void {
|
|||||||
'camera_facing' => 'CAMERA_FACING',
|
'camera_facing' => 'CAMERA_FACING',
|
||||||
'dietary' => 'DIETARY',
|
'dietary' => 'DIETARY',
|
||||||
'scale_gateway_url' => 'SCALE_GATEWAY_URL',
|
'scale_gateway_url' => 'SCALE_GATEWAY_URL',
|
||||||
'spesa_provider' => 'SPESA_PROVIDER',
|
|
||||||
'spesa_ai_prompt' => 'SPESA_AI_PROMPT',
|
|
||||||
];
|
];
|
||||||
// Boolean keys
|
// Boolean keys
|
||||||
$boolMap = [
|
$boolMap = [
|
||||||
@@ -5194,417 +5171,6 @@ function bringSuggestItems(PDO $db): void {
|
|||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 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 first
|
|
||||||
$searchResults = dupliclickCatalogSearch($query, $baseHeaders);
|
|
||||||
if ($searchResults === null) {
|
|
||||||
echo json_encode(['error' => 'Errore nella ricerca']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$products = $searchResults['products'];
|
|
||||||
$total = $searchResults['total'];
|
|
||||||
|
|
||||||
if (empty($products)) {
|
|
||||||
// Fallback: try searching with spec keywords appended
|
|
||||||
$specKeywords = dupliclickExtractSpecKeywords($spec);
|
|
||||||
if ($specKeywords) {
|
|
||||||
$searchResults = dupliclickCatalogSearch($query . ' ' . $specKeywords, $baseHeaders);
|
|
||||||
if ($searchResults && !empty($searchResults['products'])) {
|
|
||||||
$products = $searchResults['products'];
|
|
||||||
$total = $searchResults['total'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
} elseif ($aiResult === null && !empty($spec)) {
|
|
||||||
// AI said no match — try refined search with spec keywords
|
|
||||||
$specKeywords = dupliclickExtractSpecKeywords($spec);
|
|
||||||
if ($specKeywords) {
|
|
||||||
$refined = dupliclickCatalogSearch($query . ' ' . $specKeywords, $baseHeaders);
|
|
||||||
if ($refined && !empty($refined['products'])) {
|
|
||||||
$refinedFormatted = array_map('formatDupliclickProduct', array_slice($refined['products'], 0, 10));
|
|
||||||
$aiResult2 = aiSelectBestProduct($query, $spec, $refinedFormatted, $aiPrompt);
|
|
||||||
if ($aiResult2 !== null) {
|
|
||||||
$bestProduct = $aiResult2;
|
|
||||||
$aiUsed = true;
|
|
||||||
} else {
|
|
||||||
$bestProduct = $refinedFormatted[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
echo json_encode([
|
|
||||||
'success' => true,
|
|
||||||
'query' => $query,
|
|
||||||
'product' => $bestProduct,
|
|
||||||
'total' => $total,
|
|
||||||
'ai_used' => $aiUsed,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search DupliClick catalog and return raw products array
|
|
||||||
*/
|
|
||||||
function dupliclickCatalogSearch(string $query, array $headers): ?array {
|
|
||||||
$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 => $headers,
|
|
||||||
CURLOPT_SSL_VERIFYPEER => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
|
||||||
if (curl_errno($ch)) { curl_close($ch); return null; }
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
$data = json_decode(trim($response), true);
|
|
||||||
if (!$data || ($data['response']['status'] ?? -1) !== 0) return null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'products' => $data['data']['products'] ?? [],
|
|
||||||
'total' => $data['data']['page']['totItems'] ?? 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract meaningful product keywords from a Bring specification string,
|
|
||||||
* stripping quantities, emojis, and noise words.
|
|
||||||
*/
|
|
||||||
function dupliclickExtractSpecKeywords(string $spec): string {
|
|
||||||
if (empty($spec)) return '';
|
|
||||||
// Remove priority emojis
|
|
||||||
$clean = preg_replace('/[\x{1F534}\x{1F7E1}\x{1F7E2}]/u', '', $spec);
|
|
||||||
// Remove quantities (150g, 500ml, 2x, 1 flacone, etc.)
|
|
||||||
$clean = preg_replace('/\d+\s*(g|kg|ml|l|pz|pezzi|conf|flacon[ei]|x)\b/i', '', $clean);
|
|
||||||
$clean = preg_replace('/\d+x\d*/i', '', $clean);
|
|
||||||
// Remove standalone numbers
|
|
||||||
$clean = preg_replace('/\b\d+\b/', '', $clean);
|
|
||||||
// Remove noise words
|
|
||||||
$noise = ['senza', 'con', 'più', 'meno', 'circa', 'tipo', 'lidl', 'coop', 'conad', 'esselunga'];
|
|
||||||
$clean = preg_replace('/\b(' . implode('|', $noise) . ')\b/i', '', $clean);
|
|
||||||
// Remove commas and extra spaces
|
|
||||||
$clean = preg_replace('/[,+]/', ' ', $clean);
|
|
||||||
$clean = preg_replace('/\s+/', ' ', trim($clean));
|
|
||||||
return $clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick the best product from search results using offline text-scoring (no AI needed).
|
|
||||||
* Returns null when nothing matches well enough (triggers refined search with spec keywords).
|
|
||||||
*/
|
|
||||||
function aiSelectBestProduct(string $itemName, string $spec, array $products, string $customPrompt = ''): ?array {
|
|
||||||
if (empty($products)) return null;
|
|
||||||
if (count($products) === 1) return $products[0];
|
|
||||||
|
|
||||||
$stop = ['di','del','della','dei','degli','dalle','delle','da','in','con','per','su',
|
|
||||||
'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli',
|
|
||||||
'allo','gr','kg','ml','lt','cl','pz','conf','pack'];
|
|
||||||
|
|
||||||
$tokenize = function(string $s) use ($stop): array {
|
|
||||||
$clean = mb_strtolower(preg_replace('/[^\p{L}0-9\s]/u', ' ', $s), 'UTF-8');
|
|
||||||
return array_values(array_filter(
|
|
||||||
preg_split('/\s+/', trim($clean)),
|
|
||||||
fn($t) => mb_strlen($t, 'UTF-8') > 2 && !in_array($t, $stop)
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
$queryTokens = $tokenize($itemName);
|
|
||||||
$specTokens = $tokenize($spec);
|
|
||||||
|
|
||||||
if (empty($queryTokens)) return $products[0];
|
|
||||||
|
|
||||||
// Variant conflict pairs: if spec says X, penalise products containing opposite
|
|
||||||
$variantConflicts = [
|
|
||||||
'cubetti' => ['fette','affettata','intera','arrotolata'],
|
|
||||||
'fette' => ['cubetti','dadini'],
|
|
||||||
'cotto' => ['crudo','stagionato'],
|
|
||||||
'crudo' => ['cotto'],
|
|
||||||
'intero' => ['macinato','tritato','cubetti','fette'],
|
|
||||||
'macinato' => ['intero'],
|
|
||||||
'biologico' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Category mismatch: if query implies a category, penalise products from the wrong one
|
|
||||||
$categoryGuards = [
|
|
||||||
['query' => ['frutta','mele','pere','pesche','fragole','uva','arance','limoni','banane','kiwi'],
|
|
||||||
'exclude' => ['succo','succhi','nettare','sciroppo','aranciata','bevanda','bibita']],
|
|
||||||
['query' => ['verdura','spinaci','zucchine','carote','finocchio','sedano','broccoli'],
|
|
||||||
'exclude' => ['surgelat','succo']],
|
|
||||||
['query' => ['formaggio','mozzarella','parmigiano','ricotta','pecorino'],
|
|
||||||
'exclude' => ['ravioli','tortellini','cannelloni','lasagne','pizza']],
|
|
||||||
['query' => ['pasta','spaghetti','penne','fusilli','rigatoni','tagliatelle'],
|
|
||||||
'exclude' => ['insalata','minestra','zuppa','brodo']],
|
|
||||||
];
|
|
||||||
|
|
||||||
$scores = [];
|
|
||||||
foreach ($products as $idx => $product) {
|
|
||||||
$productName = $product['name'] ?? '';
|
|
||||||
$productBrand = $product['brand'] ?? '';
|
|
||||||
$productTokens = $tokenize($productName . ' ' . $productBrand);
|
|
||||||
$nameLower = mb_strtolower($productName, 'UTF-8');
|
|
||||||
$score = 0;
|
|
||||||
|
|
||||||
// --- Token overlap: query vs product ---
|
|
||||||
foreach ($queryTokens as $qt) {
|
|
||||||
foreach ($productTokens as $pt) {
|
|
||||||
if ($qt === $pt) { $score += 6; break; }
|
|
||||||
if (str_contains($pt, $qt) || str_contains($qt, $pt)) { $score += 2; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Spec tokens get extra weight (user specified variant) ---
|
|
||||||
foreach ($specTokens as $st) {
|
|
||||||
foreach ($productTokens as $pt) {
|
|
||||||
if ($st === $pt) { $score += 8; break; }
|
|
||||||
if (str_contains($pt, $st) || str_contains($st, $pt)) { $score += 3; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- First-token anchor bonus ---
|
|
||||||
if (!empty($queryTokens) && !empty($productTokens) && $queryTokens[0] === $productTokens[0]) {
|
|
||||||
$score += 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Category mismatch penalty ---
|
|
||||||
foreach ($categoryGuards as $guard) {
|
|
||||||
if (!empty(array_intersect($queryTokens, $guard['query']))) {
|
|
||||||
foreach ($guard['exclude'] as $exc) {
|
|
||||||
if (str_contains($nameLower, $exc)) { $score -= 50; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Variant conflict penalty ---
|
|
||||||
foreach ($specTokens as $st) {
|
|
||||||
if (isset($variantConflicts[$st])) {
|
|
||||||
foreach ($variantConflicts[$st] as $conflict) {
|
|
||||||
if (str_contains($nameLower, $conflict)) { $score -= 20; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$scores[$idx] = $score;
|
|
||||||
}
|
|
||||||
|
|
||||||
arsort($scores);
|
|
||||||
reset($scores);
|
|
||||||
$topIdx = key($scores);
|
|
||||||
$topScore = current($scores);
|
|
||||||
next($scores);
|
|
||||||
$secondScore = current($scores) ?: 0;
|
|
||||||
|
|
||||||
// Return null (triggers spec-refined search) only when spec is given and no product
|
|
||||||
// matches well, so the caller can retry with more specific keywords.
|
|
||||||
if (!empty($spec) && $topScore < 4) return null;
|
|
||||||
|
|
||||||
// Otherwise return the best scoring result (fallback to first if score is 0)
|
|
||||||
return $products[$topIdx];
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== SHARED APP DATA FUNCTIONS =====
|
// ===== SHARED APP DATA FUNCTIONS =====
|
||||||
|
|
||||||
function appSettingsGet(PDO $db): void {
|
function appSettingsGet(PDO $db): void {
|
||||||
|
|||||||
@@ -499,14 +499,6 @@ body {
|
|||||||
}
|
}
|
||||||
.aw-cmp-legend-you { color: #16a34a; }
|
.aw-cmp-legend-you { color: #16a34a; }
|
||||||
.aw-cmp-legend-avg { color: #f97316; }
|
.aw-cmp-legend-avg { color: #f97316; }
|
||||||
/* Annual totals + range note */
|
|
||||||
.aw-cmp-range {
|
|
||||||
font-size: 0.67rem;
|
|
||||||
color: #6b7280;
|
|
||||||
margin: 1px 0 4px;
|
|
||||||
padding: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
/* Inline status below bars */
|
/* Inline status below bars */
|
||||||
.aw-status-inline {
|
.aw-status-inline {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -1907,57 +1899,6 @@ body {
|
|||||||
color: #dc2626;
|
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,
|
.shopping-item-remove:hover,
|
||||||
.shopping-item-remove:active {
|
.shopping-item-remove:active {
|
||||||
background: #fee2e2;
|
background: #fee2e2;
|
||||||
@@ -5303,215 +5244,6 @@ body {
|
|||||||
30% { transform: translateY(-6px); opacity: 1; }
|
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 ====== */
|
||||||
.recipe-archive {
|
.recipe-archive {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+6
-436
@@ -1553,14 +1553,6 @@ const _debouncedSyncSettings = debounce(function() {
|
|||||||
pref_zerowaste: s.pref_zerowaste,
|
pref_zerowaste: s.pref_zerowaste,
|
||||||
dietary: s.dietary,
|
dietary: s.dietary,
|
||||||
appliances: s.appliances,
|
appliances: s.appliances,
|
||||||
spesa_provider: s.spesa_provider,
|
|
||||||
spesa_ai_prompt: s.spesa_ai_prompt,
|
|
||||||
spesa_email: s.spesa_email,
|
|
||||||
spesa_password: s.spesa_password,
|
|
||||||
spesa_logged_in: s.spesa_logged_in,
|
|
||||||
spesa_user: s.spesa_user,
|
|
||||||
spesa_data: s.spesa_data,
|
|
||||||
spesa_token: s.spesa_token
|
|
||||||
};
|
};
|
||||||
api('app_settings_save', {}, 'POST', { settings: { user_prefs: shared } }).catch(() => {});
|
api('app_settings_save', {}, 'POST', { settings: { user_prefs: shared } }).catch(() => {});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -1576,8 +1568,8 @@ async function syncSettingsFromDB() {
|
|||||||
const s = getSettings();
|
const s = getSettings();
|
||||||
const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
|
const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
|
||||||
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
|
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
|
||||||
'camera_facing','scale_enabled','scale_gateway_url','spesa_provider',
|
'camera_facing','scale_enabled','scale_gateway_url',
|
||||||
'spesa_ai_prompt','meal_plan_enabled','tts_enabled','tts_url','tts_token',
|
'meal_plan_enabled','tts_enabled','tts_url','tts_token',
|
||||||
'tts_method','tts_auth_type','tts_content_type','tts_payload_key'];
|
'tts_method','tts_auth_type','tts_content_type','tts_payload_key'];
|
||||||
for (const key of serverKeys) {
|
for (const key of serverKeys) {
|
||||||
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
|
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
|
||||||
@@ -1589,16 +1581,6 @@ async function syncSettingsFromDB() {
|
|||||||
// Also load review_confirmed from DB
|
// Also load review_confirmed from DB
|
||||||
const res = await api('app_settings_get');
|
const res = await api('app_settings_get');
|
||||||
if (res.success && res.settings) {
|
if (res.success && res.settings) {
|
||||||
// Spesa credentials still come from DB (not .env)
|
|
||||||
if (res.settings.user_prefs) {
|
|
||||||
const db = res.settings.user_prefs;
|
|
||||||
for (const key of ['spesa_email','spesa_password','spesa_logged_in',
|
|
||||||
'spesa_user','spesa_data','spesa_token']) {
|
|
||||||
if (db[key] !== undefined) s[key] = db[key];
|
|
||||||
}
|
|
||||||
_settingsCache = s;
|
|
||||||
localStorage.setItem('evershelf_settings', JSON.stringify(s));
|
|
||||||
}
|
|
||||||
if (res.settings.review_confirmed) {
|
if (res.settings.review_confirmed) {
|
||||||
_reviewConfirmedCache = res.settings.review_confirmed;
|
_reviewConfirmedCache = res.settings.review_confirmed;
|
||||||
}
|
}
|
||||||
@@ -1624,7 +1606,6 @@ async function loadSettingsUI() {
|
|||||||
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
|
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
|
||||||
loadCameraDevices();
|
loadCameraDevices();
|
||||||
renderAppliances(s.appliances || []);
|
renderAppliances(s.appliances || []);
|
||||||
loadSpesaSettings();
|
|
||||||
const mealPlanEnabled = s.meal_plan_enabled !== false;
|
const mealPlanEnabled = s.meal_plan_enabled !== false;
|
||||||
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
|
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
|
||||||
if (mpEnabledEl) mpEnabledEl.checked = mealPlanEnabled;
|
if (mpEnabledEl) mpEnabledEl.checked = mealPlanEnabled;
|
||||||
@@ -1692,8 +1673,8 @@ async function loadSettingsUI() {
|
|||||||
const serverKeys = ['gemini_key','bring_email','bring_password',
|
const serverKeys = ['gemini_key','bring_email','bring_password',
|
||||||
'default_persons','pref_veloce','pref_pocafame','pref_scadenze',
|
'default_persons','pref_veloce','pref_pocafame','pref_scadenze',
|
||||||
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
|
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
|
||||||
'camera_facing','scale_enabled','scale_gateway_url','spesa_provider',
|
'camera_facing','scale_enabled','scale_gateway_url',
|
||||||
'spesa_ai_prompt','meal_plan_enabled',
|
'meal_plan_enabled',
|
||||||
'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type',
|
'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type',
|
||||||
'tts_content_type','tts_payload_key'];
|
'tts_content_type','tts_payload_key'];
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -1906,9 +1887,6 @@ async function saveSettings() {
|
|||||||
if (ttsPayloadKeyEl2) s.tts_payload_key = ttsPayloadKeyEl2.value.trim() || 'message';
|
if (ttsPayloadKeyEl2) s.tts_payload_key = ttsPayloadKeyEl2.value.trim() || 'message';
|
||||||
const ttsExtraEl2 = document.getElementById('setting-tts-extra-fields');
|
const ttsExtraEl2 = document.getElementById('setting-tts-extra-fields');
|
||||||
if (ttsExtraEl2) s.tts_extra_fields = ttsExtraEl2.value.trim();
|
if (ttsExtraEl2) s.tts_extra_fields = ttsExtraEl2.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();
|
|
||||||
// Scale settings
|
// Scale settings
|
||||||
const scaleEnabledEl = document.getElementById('setting-scale-enabled');
|
const scaleEnabledEl = document.getElementById('setting-scale-enabled');
|
||||||
if (scaleEnabledEl) s.scale_enabled = scaleEnabledEl.checked;
|
if (scaleEnabledEl) s.scale_enabled = scaleEnabledEl.checked;
|
||||||
@@ -1934,8 +1912,6 @@ async function saveSettings() {
|
|||||||
camera_facing: s.camera_facing,
|
camera_facing: s.camera_facing,
|
||||||
scale_enabled: s.scale_enabled,
|
scale_enabled: s.scale_enabled,
|
||||||
scale_gateway_url: s.scale_gateway_url,
|
scale_gateway_url: s.scale_gateway_url,
|
||||||
spesa_provider: s.spesa_provider,
|
|
||||||
spesa_ai_prompt: s.spesa_ai_prompt,
|
|
||||||
meal_plan_enabled: s.meal_plan_enabled,
|
meal_plan_enabled: s.meal_plan_enabled,
|
||||||
tts_enabled: s.tts_enabled,
|
tts_enabled: s.tts_enabled,
|
||||||
tts_url: s.tts_url,
|
tts_url: s.tts_url,
|
||||||
@@ -2374,8 +2350,7 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60,
|
|||||||
<span class="aw-cmp-legend-you">▮ ${youLabel} <strong>${myRate}%</strong></span>
|
<span class="aw-cmp-legend-you">▮ ${youLabel} <strong>${myRate}%</strong></span>
|
||||||
<span class="aw-cmp-legend-avg">${country} <strong>${avgRate}%</strong> ▮</span>
|
<span class="aw-cmp-legend-avg">${country} <strong>${avgRate}%</strong> ▮</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="aw-cmp-range">${annualInfo}</p>
|
<p class="aw-status-inline ${statusCls}">${statusMsg} · ${annualInfo}</p>
|
||||||
<p class="aw-status-inline ${statusCls}">${statusMsg}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${allBadges.length > 0 ? `<div id="aw-badges-row" class="aw-savings-row">${initBadges}</div>` : ''}
|
${allBadges.length > 0 ? `<div id="aw-badges-row" class="aw-savings-row">${initBadges}</div>` : ''}
|
||||||
@@ -7225,7 +7200,6 @@ async function selectProductForAction(productId) {
|
|||||||
let shoppingListUUID = '';
|
let shoppingListUUID = '';
|
||||||
let shoppingItems = [];
|
let shoppingItems = [];
|
||||||
let suggestionItems = [];
|
let suggestionItems = [];
|
||||||
let shoppingPrices = {}; // { itemName: { product, searched: true } }
|
|
||||||
let _spesaScanTarget = null; // { name, rawName, idx } when tapping item to scan
|
let _spesaScanTarget = null; // { name, rawName, idx } when tapping item to scan
|
||||||
|
|
||||||
// ===== SHOPPING TABS =====
|
// ===== SHOPPING TABS =====
|
||||||
@@ -7512,41 +7486,6 @@ function logOperation(action, details) {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// Persist to shared DB
|
|
||||||
api('app_settings_save', {}, 'POST', { settings: { shopping_prices: toSave } }).catch(() => {});
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadShoppingPrices() {
|
|
||||||
try {
|
|
||||||
const res = await api('app_settings_get');
|
|
||||||
if (res.success && res.settings && res.settings.shopping_prices) {
|
|
||||||
shoppingPrices = res.settings.shopping_prices;
|
|
||||||
}
|
|
||||||
} catch (e) { shoppingPrices = {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a better search query from item name + specification
|
// Build a better search query from item name + specification
|
||||||
function buildSearchQuery(item) {
|
function buildSearchQuery(item) {
|
||||||
// Only use the item name for search - specification confuses the search engine
|
// Only use the item name for search - specification confuses the search engine
|
||||||
@@ -8037,18 +7976,6 @@ async function loadShoppingList() {
|
|||||||
}
|
}
|
||||||
shoppingItems = newItems;
|
shoppingItems = newItems;
|
||||||
|
|
||||||
// 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();
|
|
||||||
// Sync urgente local tags from Bring specification (items marked urgent by us or manually)
|
// Sync urgente local tags from Bring specification (items marked urgent by us or manually)
|
||||||
_syncTagsFromBringSpec();
|
_syncTagsFromBringSpec();
|
||||||
renderShoppingItems();
|
renderShoppingItems();
|
||||||
@@ -8102,26 +8029,10 @@ async function renderShoppingItems() {
|
|||||||
|
|
||||||
if (shoppingItems.length === 0) {
|
if (shoppingItems.length === 0) {
|
||||||
container.innerHTML = `<div class="empty-state" style="padding:20px"><div class="empty-state-icon">✅</div><p>${t('shopping.empty')}</p></div>`;
|
container.innerHTML = `<div class="empty-state" style="padding:20px"><div class="empty-state-icon">✅</div><p>${t('shopping.empty')}</p></div>`;
|
||||||
updateSpesaTotal();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = getSettings();
|
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 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build section groups, sorted by urgency weight within each section
|
// Build section groups, sorted by urgency weight within each section
|
||||||
const TAG_LABELS = { urgente: t('shopping.tag_urgent'), prio: t('shopping.tag_priority'), check: t('shopping.tag_check') };
|
const TAG_LABELS = { urgente: t('shopping.tag_urgent'), prio: t('shopping.tag_priority'), check: t('shopping.tag_check') };
|
||||||
@@ -8175,8 +8086,6 @@ async function renderShoppingItems() {
|
|||||||
|
|
||||||
for (const { item, idx, smartData, urgency } of group.items) {
|
for (const { item, idx, smartData, urgency } of group.items) {
|
||||||
const catIcon = CATEGORY_ICONS[guessCategoryFromName(item.name)] || '🛒';
|
const catIcon = CATEGORY_ICONS[guessCategoryFromName(item.name)] || '🛒';
|
||||||
const priceKey = item.name.toLowerCase();
|
|
||||||
const priceData = shoppingPrices[priceKey];
|
|
||||||
const bgStyle = urgency && URGENCY_BG[urgency] ? ` style="background:${URGENCY_BG[urgency]}"` : '';
|
const bgStyle = urgency && URGENCY_BG[urgency] ? ` style="background:${URGENCY_BG[urgency]}"` : '';
|
||||||
const localTags = getShoppingTags(item.name);
|
const localTags = getShoppingTags(item.name);
|
||||||
|
|
||||||
@@ -8203,44 +8112,8 @@ async function renderShoppingItems() {
|
|||||||
).join('')}
|
).join('')}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
let detailHtml = '';
|
|
||||||
let priceTag = '';
|
|
||||||
let spesaBar = '';
|
|
||||||
if (hasSpesa) {
|
|
||||||
if (priceData && priceData.loading) {
|
|
||||||
detailHtml = `<div class="spesa-loading">🔍 ${t('shopping.price_searching')}</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 || '');
|
|
||||||
priceTag = est
|
|
||||||
? `<div class="shopping-item-price">~€${est.estimated.toFixed(2)}</div>`
|
|
||||||
: `<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="event.stopPropagation(); searchItemPrice(${idx}, true)" title="${t('shopping.search_action')}">🔄 ${t('shopping.search_action')}</button>
|
|
||||||
<a href="${escapeHtml(p.url)}" target="_blank" class="spesa-bar-btn" title="${escapeHtml(p.name)}" onclick="event.stopPropagation()">🔗 ${t('shopping.open_action')}</a>
|
|
||||||
</div>`;
|
|
||||||
} else if (priceData && priceData.searched && !priceData.product) {
|
|
||||||
detailHtml = `<div class="spesa-detail-left"><span class="spesa-not-found">${t('shopping.not_found')}</span></div>`;
|
|
||||||
spesaBar = `<div class="spesa-bar">
|
|
||||||
<button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx}, true)" title="${t('btn.retry').replace('🔄 ', '')}">${t('btn.retry')}</button>
|
|
||||||
</div>`;
|
|
||||||
} else {
|
|
||||||
spesaBar = `<div class="spesa-bar">
|
|
||||||
<button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx})" title="${t('shopping.search_price')}">🔍 ${t('shopping.search_price')}</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="shopping-item ${priceData?.product?.promo ? 'has-promo' : ''}" id="shop-item-${idx}" onclick="openScanForItem(${idx})" title="${t('shopping.tap_to_scan')}"${bgStyle}>
|
<div class="shopping-item" id="shop-item-${idx}" onclick="openScanForItem(${idx})" title="${t('shopping.tap_to_scan')}"${bgStyle}>
|
||||||
<span class="shopping-item-icon">${catIcon}</span>
|
<span class="shopping-item-icon">${catIcon}</span>
|
||||||
<div class="shopping-item-body">
|
<div class="shopping-item-body">
|
||||||
<div class="shopping-item-top">
|
<div class="shopping-item-top">
|
||||||
@@ -8251,15 +8124,12 @@ async function renderShoppingItems() {
|
|||||||
</div>
|
</div>
|
||||||
${_specDisplayText(item.specification) ? `<div class="shopping-item-spec">${escapeHtml(_specDisplayText(item.specification))}</div>` : ''}
|
${_specDisplayText(item.specification) ? `<div class="shopping-item-spec">${escapeHtml(_specDisplayText(item.specification))}</div>` : ''}
|
||||||
${(urgencyBadge || freqBadge || localTagHtml) ? `<div class="shopping-item-badges">${urgencyBadge}${freqBadge}${localTagHtml}</div>` : ''}
|
${(urgencyBadge || freqBadge || localTagHtml) ? `<div class="shopping-item-badges">${urgencyBadge}${freqBadge}${localTagHtml}</div>` : ''}
|
||||||
${detailHtml}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="shopping-item-right" onclick="event.stopPropagation()">
|
<div class="shopping-item-right" onclick="event.stopPropagation()">
|
||||||
${priceTag}
|
|
||||||
<button class="shopping-item-tag-btn" onclick="toggleShoppingTagMenu(this)" title="${t('shopping.tag_title')}">🏷️</button>
|
<button class="shopping-item-tag-btn" onclick="toggleShoppingTagMenu(this)" title="${t('shopping.tag_title')}">🏷️</button>
|
||||||
<button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="${t('shopping.remove_title')}">✕</button>
|
<button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="${t('shopping.remove_title')}">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${spesaBar}
|
|
||||||
<div class="shopping-tag-menu-container" style="display:none">${tagMenu}</div>
|
<div class="shopping-tag-menu-container" style="display:none">${tagMenu}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -8267,7 +8137,6 @@ async function renderShoppingItems() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
updateSpesaTotal();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleShoppingTagMenu(btn) {
|
function toggleShoppingTagMenu(btn) {
|
||||||
@@ -8279,157 +8148,6 @@ function toggleShoppingTagMenu(btn) {
|
|||||||
container.style.display = isOpen ? 'none' : 'block';
|
container.style.display = isOpen ? 'none' : 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = t('shopping.found_count').replace('{found}', found).replace('{total}', shoppingItems.length);
|
|
||||||
if (promoSaved > 0) {
|
|
||||||
detail += ` ${t('shopping.savings_offers').replace('{amount}', promoSaved.toFixed(2))}`;
|
|
||||||
}
|
|
||||||
detailEl.textContent = detail;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchItemPrice(idx, force = false) {
|
|
||||||
const item = shoppingItems[idx];
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
const priceKey = item.name.toLowerCase();
|
|
||||||
const cached = shoppingPrices[priceKey];
|
|
||||||
// Invalidate cache if spec changed (e.g. item was updated in Bring)
|
|
||||||
if (!force && cached && cached.searched) {
|
|
||||||
const cachedSpec = (cached.spec || '').toLowerCase();
|
|
||||||
const currentSpec = (item.specification || '').toLowerCase();
|
|
||||||
if (cachedSpec === currentSpec) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = getSettings();
|
|
||||||
const provider = s.spesa_provider || 'dupliclick';
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
shoppingPrices[priceKey] = { searched: false, loading: true, product: null };
|
|
||||||
renderShoppingItems();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Send item name as query, spec separately for AI selection (strip urgency markers)
|
|
||||||
const searchQ = item.name;
|
|
||||||
const spec = _cleanSpecForSearch(item.specification || '');
|
|
||||||
|
|
||||||
const s2 = getSettings();
|
|
||||||
const aiPrompt = s2.spesa_ai_prompt || '';
|
|
||||||
const res = await api(`${provider}_search`, {
|
|
||||||
q: searchQ,
|
|
||||||
spec: spec,
|
|
||||||
prompt: aiPrompt
|
|
||||||
});
|
|
||||||
if (res.success && res.product) {
|
|
||||||
shoppingPrices[priceKey] = { searched: true, product: res.product, spec: _cleanSpecForSearch(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(t('settings.spesa.configure_first'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
s.spesa_logged_in = true;
|
|
||||||
s.spesa_token = 'server';
|
|
||||||
saveSettings(s);
|
|
||||||
} catch (e) {
|
|
||||||
showToast(t('settings.spesa.configure_first'), '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(t('shopping.all_searched'), 'info');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
const totalToSearch = toSearch.length;
|
|
||||||
|
|
||||||
for (let i = 0; i < toSearch.length; i++) {
|
|
||||||
const item = toSearch[i];
|
|
||||||
btn.innerHTML = `⏳ ${t('shopping.searching_progress').replace('{current}', i + 1).replace('{total}', 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: _cleanSpecForSearch(item.specification || ''),
|
|
||||||
prompt: aiPrompt
|
|
||||||
});
|
|
||||||
if (res.success && res.product) {
|
|
||||||
shoppingPrices[priceKey] = { searched: true, product: res.product, spec: _cleanSpecForSearch(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 = `🔍 ${t('shopping.search_prices')}`;
|
|
||||||
showToast(t('shopping.search_complete').replace('{count}', totalToSearch), 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeBringItem(idx) {
|
async function removeBringItem(idx) {
|
||||||
const item = shoppingItems[idx];
|
const item = shoppingItems[idx];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
@@ -10884,18 +10602,6 @@ function generateScreensaverFact() {
|
|||||||
// Greeting based on time
|
// Greeting based on time
|
||||||
const greeting = hour < 12 ? 'Buongiorno' : hour < 18 ? 'Buon pomeriggio' : 'Buonasera';
|
const greeting = hour < 12 ? 'Buongiorno' : hour < 18 ? 'Buon pomeriggio' : 'Buonasera';
|
||||||
|
|
||||||
// Estimated shopping total
|
|
||||||
let spesaTotal = 0;
|
|
||||||
let spesaPriced = 0;
|
|
||||||
for (const item of shop) {
|
|
||||||
const pd = shoppingPrices[item.name.toLowerCase()];
|
|
||||||
if (pd && pd.product) {
|
|
||||||
const est = estimateItemPrice(pd.product, item.specification || pd.spec || '');
|
|
||||||
spesaTotal += est ? est.estimated : pd.product.price;
|
|
||||||
spesaPriced++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Random item picker
|
// Random item picker
|
||||||
const rItem = (arr) => arr.length ? arr[Math.floor(Math.random() * arr.length)] : null;
|
const rItem = (arr) => arr.length ? arr[Math.floor(Math.random() * arr.length)] : null;
|
||||||
|
|
||||||
@@ -10959,12 +10665,6 @@ function generateScreensaverFact() {
|
|||||||
const names = shop.slice(0, 4).map(i => i.name);
|
const names = shop.slice(0, 4).map(i => i.name);
|
||||||
return `Nella spesa: ${names.join(', ')}${shop.length > 4 ? '...' : ''}`;
|
return `Nella spesa: ${names.join(', ')}${shop.length > 4 ? '...' : ''}`;
|
||||||
});
|
});
|
||||||
if (spesaTotal > 0) {
|
|
||||||
facts.push(() => `Il totale previsto per la spesa è circa €${spesaTotal.toFixed(2)}.`);
|
|
||||||
if (spesaPriced < shop.length) {
|
|
||||||
facts.push(() => `Spesa stimata: €${spesaTotal.toFixed(2)} (${spesaPriced} di ${shop.length} prodotti con prezzo).`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (shop.length === 0) {
|
if (shop.length === 0) {
|
||||||
facts.push(() => `La lista della spesa è vuota. Tutto a posto!`);
|
facts.push(() => `La lista della spesa è vuota. Tutto a posto!`);
|
||||||
@@ -11719,133 +11419,3 @@ async function _backgroundBringSync() {
|
|||||||
} catch (e) { /* silent — best effort */ }
|
} catch (e) { /* silent — best effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 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(t('settings.spesa.missing_credentials'), '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 = `⏳ ${t('settings.spesa.login_in_progress')}`;
|
|
||||||
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>${t('settings.spesa.login_error_prefix')}</strong> ${escapeHtml(res.error)}`;
|
|
||||||
statusEl.style.display = 'block';
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = t('settings.spesa.login_btn');
|
|
||||||
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 : t('settings.spesa.login_success_default');
|
|
||||||
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">👤 ${t('settings.spesa.result_name_label')}</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">💳 ${t('settings.spesa.result_card_label')}</span><span class="data-value">${escapeHtml(user.fidelityCard)}</span></div>`;
|
|
||||||
if (shipping.addressName) html += `<div class="data-row"><span class="data-label">🏪 ${t('settings.spesa.result_pickup_label')}</span><span class="data-value">${escapeHtml(shipping.addressName)}</span></div>`;
|
|
||||||
if (fidelityPts) html += `<div class="data-row"><span class="data-label">⭐ ${t('settings.spesa.result_points_label')}</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>${t('settings.spesa.login_network_error_prefix')}</strong> ${escapeHtml(e.message)}`;
|
|
||||||
statusEl.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = t('settings.spesa.login_btn');
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = t('settings.spesa.connected_relogin');
|
|
||||||
loginBtn.className = 'btn btn-large btn-secondary full-width mt-2';
|
|
||||||
}
|
|
||||||
if (statusEl) {
|
|
||||||
statusEl.className = 'dupliclick-status success';
|
|
||||||
statusEl.innerHTML = `✅ <strong>${t('settings.spesa.connected_as').replace('{name}', `${escapeHtml(s.spesa_user.firstName || '')} ${escapeHtml(s.spesa_user.lastName || '')}`.trim())}</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">👤 ${t('settings.spesa.result_name_label')}</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">💳 ${t('settings.spesa.result_card_label')}</span><span class="data-value">${escapeHtml(user.fidelityCard)}</span></div>`;
|
|
||||||
if (shipping.addressName) html += `<div class="data-row"><span class="data-label">🏪 ${t('settings.spesa.result_pickup_label')}</span><span class="data-value">${escapeHtml(shipping.addressName)}</span></div>`;
|
|
||||||
if (fidelityPts) html += `<div class="data-row"><span class="data-label">⭐ ${t('settings.spesa.result_points_label')}</span><span class="data-value">${fidelityPts.value || 0}</span></div>`;
|
|
||||||
html += '</div></div>';
|
|
||||||
resultEl.innerHTML = html;
|
|
||||||
resultEl.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -500,39 +500,6 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: Recipe deleted
|
description: Recipe deleted
|
||||||
|
|
||||||
/index.php?action=dupliclick_login:
|
|
||||||
post:
|
|
||||||
summary: Login to DupliClick (online shopping)
|
|
||||||
tags: [DupliClick]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
email:
|
|
||||||
type: string
|
|
||||||
password:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Login successful
|
|
||||||
|
|
||||||
/index.php?action=dupliclick_search:
|
|
||||||
get:
|
|
||||||
summary: Search DupliClick product catalog
|
|
||||||
tags: [DupliClick]
|
|
||||||
parameters:
|
|
||||||
- name: q
|
|
||||||
in: query
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Search results
|
|
||||||
|
|
||||||
/index.php?action=tts_proxy:
|
/index.php?action=tts_proxy:
|
||||||
post:
|
post:
|
||||||
summary: Proxy TTS request to external endpoint
|
summary: Proxy TTS request to external endpoint
|
||||||
@@ -685,7 +652,5 @@ tags:
|
|||||||
description: Recipe storage
|
description: Recipe storage
|
||||||
- name: Settings
|
- name: Settings
|
||||||
description: Application and server settings
|
description: Application and server settings
|
||||||
- name: DupliClick
|
|
||||||
description: DupliClick online shopping integration
|
|
||||||
- name: TTS
|
- name: TTS
|
||||||
description: Text-to-Speech proxy
|
description: Text-to-Speech proxy
|
||||||
|
|||||||
-50
@@ -565,14 +565,6 @@
|
|||||||
|
|
||||||
<!-- Tab panel: Da comprare -->
|
<!-- Tab panel: Da comprare -->
|
||||||
<div id="tab-panel-acquisto" class="tab-panel-shopping active">
|
<div id="tab-panel-acquisto" class="tab-panel-shopping active">
|
||||||
<!-- 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" data-i18n="shopping.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-current" id="shopping-current" style="display:none">
|
||||||
<div class="shopping-section-header">
|
<div class="shopping-section-header">
|
||||||
<h3 data-i18n="shopping.section_to_buy">🛍️ Da comprare</h3>
|
<h3 data-i18n="shopping.section_to_buy">🛍️ Da comprare</h3>
|
||||||
@@ -593,9 +585,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shopping-actions">
|
<div class="shopping-actions">
|
||||||
<button class="btn btn-large btn-accent" onclick="searchAllPrices()" id="btn-search-prices" data-i18n="shopping.search_prices">
|
|
||||||
🔍 Cerca tutti i prezzi
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-large btn-accent" onclick="generateSuggestions()" id="btn-suggest" data-i18n="shopping.suggest_btn">
|
<button class="btn btn-large btn-accent" onclick="generateSuggestions()" id="btn-suggest" data-i18n="shopping.suggest_btn">
|
||||||
🤖 Suggerisci cosa comprare
|
🤖 Suggerisci cosa comprare
|
||||||
</button>
|
</button>
|
||||||
@@ -697,7 +686,6 @@
|
|||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</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-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</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-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-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
|
||||||
@@ -822,44 +810,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
<!-- Camera Tab -->
|
<!-- Camera Tab -->
|
||||||
<div class="settings-panel" id="tab-camera">
|
<div class="settings-panel" id="tab-camera">
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
|
|||||||
Reference in New Issue
Block a user