feat: AI price estimation for shopping list with per-item real-time display
- Add get_shopping_price / get_all_shopping_prices API endpoints - AI (Gemini) estimates retail price per natural unit (pack, piece, bunch) instead of always per-kg — avoids absurd totals like €1609 - _calcEstimatedTotal: proper g/ml→package conversion using defQty + regex on unit_label; only 'kg'/'l' labels trigger weight/volume math - Cache key bumped to v2 to invalidate old per-kg cached entries - Suggested quantity cap lowered from 20 to 10 conf/pz - Unit mismatch guard: if totalUsed >> buyCount*5 for unit=conf, use purchase frequency instead of raw consumption rate - JS _buildPricePayload: use smartShoppingItems for qty/unit (not Bring! spec) - JS _cachedPrices: persist in sessionStorage (survives navigation); validated by _qty/_unit metadata so stale totals auto-invalidate - Price display redesigned: right-side column per row (price-col-main + price-col-unit) instead of small inline badge - fetchAllPrices: buttons disabled immediately before guard check; running total uses only current shoppingItems (not Object.values cache) - Background refresh: always silent (removed 90s interaction condition) - visibilitychange: sets _bgCall=true for shopping before refreshCurrentPage - .gitignore: add runtime data files (bring_migrate_ts, shopping_price_cache, anomaly_dismissed, opened_shelf_cache, shopping_name_cache) - Remove bring_catalog.json and bring_migrate_ts.json from tracking
This commit is contained in:
@@ -11,6 +11,11 @@ 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/bring_migrate_ts.json
|
||||||
|
data/shopping_price_cache.json
|
||||||
|
data/anomaly_dismissed.json
|
||||||
|
data/opened_shelf_cache.json
|
||||||
|
data/shopping_name_cache.json
|
||||||
data/client_debug.log
|
data/client_debug.log
|
||||||
data/rate_limits/
|
data/rate_limits/
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ function getDB(): PDO {
|
|||||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
$db->exec("PRAGMA journal_mode=WAL");
|
$db->exec("PRAGMA journal_mode=WAL");
|
||||||
$db->exec("PRAGMA foreign_keys=ON");
|
$db->exec("PRAGMA foreign_keys=ON");
|
||||||
|
$db->exec("PRAGMA synchronous=NORMAL"); // faster writes, still safe with WAL
|
||||||
|
$db->exec("PRAGMA cache_size=-8000"); // ~8 MB page cache (was 2 MB)
|
||||||
|
$db->exec("PRAGMA temp_store=MEMORY"); // temp tables in RAM
|
||||||
|
|
||||||
if ($isNew) {
|
if ($isNew) {
|
||||||
initializeDB($db);
|
initializeDB($db);
|
||||||
|
|||||||
+423
-25
@@ -16,6 +16,7 @@
|
|||||||
define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004');
|
define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004');
|
||||||
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
|
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
|
||||||
define('GH_REPO', 'dadaloop82/EverShelf');
|
define('GH_REPO', 'dadaloop82/EverShelf');
|
||||||
|
define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json');
|
||||||
|
|
||||||
/** Decode the XOR-obfuscated GitHub token at runtime. */
|
/** Decode the XOR-obfuscated GitHub token at runtime. */
|
||||||
function _ghToken(): string {
|
function _ghToken(): string {
|
||||||
@@ -410,6 +411,14 @@ try {
|
|||||||
geminiAnomalyExplain();
|
geminiAnomalyExplain();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'get_shopping_price':
|
||||||
|
getShoppingPrice($db);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get_all_shopping_prices':
|
||||||
|
getAllShoppingPrices($db);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
||||||
@@ -1213,17 +1222,19 @@ function useFromInventory(PDO $db): void {
|
|||||||
$stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
$stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
$stmt->execute([$existing['id']]);
|
$stmt->execute([$existing['id']]);
|
||||||
} else {
|
} else {
|
||||||
// Check if item is now opened (first use reduces quantity)
|
// Check if item is now opened (first use creates a fractional/partial package)
|
||||||
$wasOpened = !empty($existing['opened_at']);
|
$wasOpened = !empty($existing['opened_at']);
|
||||||
$isNowOpened = false;
|
$isNowOpened = false;
|
||||||
$unit = $prodInfo['unit'] ?? 'pz';
|
$unit = $prodInfo['unit'] ?? 'pz';
|
||||||
$defQty = (float)($prodInfo['default_quantity'] ?? 0);
|
$defQty = (float)($prodInfo['default_quantity'] ?? 0);
|
||||||
if ($unit === 'conf') {
|
if ($unit === 'conf') {
|
||||||
$w = floor($newQty + 0.001);
|
// Opened = a fractional (non-integer) quantity remains
|
||||||
$f = round($newQty - $w, 6);
|
$f = round($newQty - floor($newQty + 0.001), 6);
|
||||||
if ($f > 0.001) $isNowOpened = true;
|
if ($f > 0.001) $isNowOpened = true;
|
||||||
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $newQty < $defQty - 0.001) {
|
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0) {
|
||||||
$isNowOpened = true;
|
// Opened = remaining qty is not a clean multiple of the package size
|
||||||
|
$pkgRem = round($newQty - floor($newQty / $defQty + 0.001) * $defQty, 6);
|
||||||
|
if ($pkgRem > $defQty * 0.01) $isNowOpened = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($isNowOpened && !$wasOpened) {
|
if ($isNowOpened && !$wasOpened) {
|
||||||
@@ -1238,8 +1249,45 @@ function useFromInventory(PDO $db): void {
|
|||||||
if (!empty($existing['expiry_date']) && strtotime($existing['expiry_date']) < strtotime($openedExpiry)) {
|
if (!empty($existing['expiry_date']) && strtotime($existing['expiry_date']) < strtotime($openedExpiry)) {
|
||||||
$openedExpiry = $existing['expiry_date'];
|
$openedExpiry = $existing['expiry_date'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split opened portion from sealed packages into two separate rows:
|
||||||
|
// closed packages stay at original location, opened portion is offered to move.
|
||||||
|
if ($unit === 'conf') {
|
||||||
|
$newWhole = (int)floor($newQty + 0.001);
|
||||||
|
$newFrac = round($newQty - $newWhole, 6);
|
||||||
|
if ($newFrac > 0.001 && $newWhole >= 1) {
|
||||||
|
// Keep whole confs in original row (no opened_at, sealed expiry unchanged)
|
||||||
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
|
$stmt->execute([$newWhole, $existing['id']]);
|
||||||
|
// New row for the opened fraction with short shelf-life expiry
|
||||||
|
$stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)");
|
||||||
|
$stmt->execute([$productId, $location, $newFrac, $openedExpiry, $vacuum]);
|
||||||
|
$openedId = (int)$db->lastInsertId();
|
||||||
|
} else {
|
||||||
|
// Only the opened fraction remains (≤ 1 conf) — single row
|
||||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
$stmt->execute([$newQty, $openedExpiry, $existing['id']]);
|
$stmt->execute([$newQty, $openedExpiry, $existing['id']]);
|
||||||
|
}
|
||||||
|
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0) {
|
||||||
|
$newWholePkgs = (int)floor($newQty / $defQty + 0.001);
|
||||||
|
$newRemainder = round($newQty - $newWholePkgs * $defQty, 6);
|
||||||
|
if ($newRemainder > $defQty * 0.01 && $newWholePkgs >= 1) {
|
||||||
|
// Keep whole packages in original row (no opened_at, sealed expiry unchanged)
|
||||||
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
|
$stmt->execute([$newWholePkgs * $defQty, $existing['id']]);
|
||||||
|
// New row for the opened partial package with short shelf-life expiry
|
||||||
|
$stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)");
|
||||||
|
$stmt->execute([$productId, $location, $newRemainder, $openedExpiry, $vacuum]);
|
||||||
|
$openedId = (int)$db->lastInsertId();
|
||||||
|
} else {
|
||||||
|
// Only the opened remainder (last package) — single row
|
||||||
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
|
$stmt->execute([$newQty, $openedExpiry, $existing['id']]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
|
$stmt->execute([$newQty, $openedExpiry, $existing['id']]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
$stmt->execute([$newQty, $existing['id']]);
|
$stmt->execute([$newQty, $existing['id']]);
|
||||||
@@ -1260,12 +1308,16 @@ function useFromInventory(PDO $db): void {
|
|||||||
|
|
||||||
$remaining = $newQty;
|
$remaining = $newQty;
|
||||||
|
|
||||||
// Check if opened part remains (for non-split path)
|
// Check if opened part remains (for non-split path, only when not already set by split above)
|
||||||
if ($remaining > 0 && $prodInfo && $prodInfo['unit'] === 'conf') {
|
if ($openedId === null && $remaining > 0 && $prodInfo) {
|
||||||
$w = floor($remaining + 0.001);
|
$unitFb = $prodInfo['unit'] ?? '';
|
||||||
$f = round($remaining - $w, 6);
|
$defQtyFb = (float)($prodInfo['default_quantity'] ?? 0);
|
||||||
if ($f > 0.001) {
|
if ($unitFb === 'conf') {
|
||||||
$openedId = (int)$existing['id'];
|
$f = round($remaining - floor($remaining + 0.001), 6);
|
||||||
|
if ($f > 0.001) $openedId = (int)$existing['id'];
|
||||||
|
} elseif (in_array($unitFb, ['g','kg','ml','l']) && $defQtyFb > 0) {
|
||||||
|
$pkgRemFb = round($remaining - floor($remaining / $defQtyFb + 0.001) * $defQtyFb, 6);
|
||||||
|
if ($pkgRemFb > $defQtyFb * 0.01) $openedId = (int)$existing['id'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1772,7 +1824,7 @@ function getStats(PDO $db): void {
|
|||||||
}
|
}
|
||||||
// Compute opened shelf-life using AI (with rule-based fallback + persistent cache).
|
// Compute opened shelf-life using AI (with rule-based fallback + persistent cache).
|
||||||
// The vacuum-sealed multiplier is already handled inside getOpenedShelfLifeDays.
|
// The vacuum-sealed multiplier is already handled inside getOpenedShelfLifeDays.
|
||||||
$openedDays = getOpenedShelfLifeDays($item['name'], $item['category'], $item['location'], (bool)$vacuum);
|
$openedDays = getOpenedShelfLifeDays($item['name'], $item['category'], $item['location'], (bool)$vacuum, false);
|
||||||
$computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400;
|
$computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400;
|
||||||
// Always respect the manufacturer date: if the package expires before our estimate,
|
// Always respect the manufacturer date: if the package expires before our estimate,
|
||||||
// use the manufacturer date (e.g., milk opened 2 days before its sealed expiry).
|
// use the manufacturer date (e.g., milk opened 2 days before its sealed expiry).
|
||||||
@@ -2079,6 +2131,10 @@ function getServerSettings(): void {
|
|||||||
'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true',
|
'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true',
|
||||||
'screensaver_enabled' => env('SCREENSAVER_ENABLED', 'false') === 'true',
|
'screensaver_enabled' => env('SCREENSAVER_ENABLED', 'false') === 'true',
|
||||||
'screensaver_timeout' => (int)env('SCREENSAVER_TIMEOUT', '5'),
|
'screensaver_timeout' => (int)env('SCREENSAVER_TIMEOUT', '5'),
|
||||||
|
'price_enabled' => env('PRICE_ENABLED', 'false') === 'true',
|
||||||
|
'price_country' => env('PRICE_COUNTRY', 'Italia'),
|
||||||
|
'price_currency' => env('PRICE_CURRENCY', 'EUR'),
|
||||||
|
'price_update_months' => (int)env('PRICE_UPDATE_MONTHS', '3'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2112,6 +2168,8 @@ 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',
|
||||||
|
'price_country' => 'PRICE_COUNTRY',
|
||||||
|
'price_currency' => 'PRICE_CURRENCY',
|
||||||
];
|
];
|
||||||
// Boolean keys
|
// Boolean keys
|
||||||
$boolMap = [
|
$boolMap = [
|
||||||
@@ -2125,11 +2183,13 @@ function saveSettings(): void {
|
|||||||
'scale_enabled' => 'SCALE_ENABLED',
|
'scale_enabled' => 'SCALE_ENABLED',
|
||||||
'meal_plan_enabled' => 'MEAL_PLAN_ENABLED',
|
'meal_plan_enabled' => 'MEAL_PLAN_ENABLED',
|
||||||
'screensaver_enabled' => 'SCREENSAVER_ENABLED',
|
'screensaver_enabled' => 'SCREENSAVER_ENABLED',
|
||||||
|
'price_enabled' => 'PRICE_ENABLED',
|
||||||
];
|
];
|
||||||
// Integer keys
|
// Integer keys
|
||||||
$intMap = [
|
$intMap = [
|
||||||
'default_persons' => 'DEFAULT_PERSONS',
|
'default_persons' => 'DEFAULT_PERSONS',
|
||||||
'screensaver_timeout' => 'SCREENSAVER_TIMEOUT',
|
'screensaver_timeout' => 'SCREENSAVER_TIMEOUT',
|
||||||
|
'price_update_months' => 'PRICE_UPDATE_MONTHS',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($keyMap as $inKey => $envKey) {
|
foreach ($keyMap as $inKey => $envKey) {
|
||||||
@@ -2266,25 +2326,30 @@ function callGeminiWithFallback(string $apiKey, array $payload, int $timeout = 3
|
|||||||
* Falls back to the rule-based estimate if AI is unavailable or returns an unusable answer.
|
* Falls back to the rule-based estimate if AI is unavailable or returns an unusable answer.
|
||||||
* Cache has no expiry — shelf-life science doesn't change; the file can be manually deleted to refresh.
|
* Cache has no expiry — shelf-life science doesn't change; the file can be manually deleted to refresh.
|
||||||
*/
|
*/
|
||||||
function getOpenedShelfLifeDays(string $name, string $category, string $location, bool $vacuumSealed = false): int {
|
function getOpenedShelfLifeDays(string $name, string $category, string $location, bool $vacuumSealed = false, bool $allowAI = true): int {
|
||||||
$cacheFile = __DIR__ . '/../data/opened_shelf_cache.json';
|
$cacheFile = __DIR__ . '/../data/opened_shelf_cache.json';
|
||||||
$cacheKey = md5(mb_strtolower($name) . '|' . mb_strtolower($location));
|
$cacheKey = md5(mb_strtolower($name) . '|' . mb_strtolower($location));
|
||||||
|
|
||||||
// Load cache
|
// Static in-memory cache: the file is read only ONCE per PHP request,
|
||||||
|
// even when this function is called for many items in a loop (e.g. getStats).
|
||||||
|
static $cache = null;
|
||||||
|
static $cacheDirty = false;
|
||||||
|
if ($cache === null) {
|
||||||
$cache = [];
|
$cache = [];
|
||||||
if (file_exists($cacheFile)) {
|
if (file_exists($cacheFile)) {
|
||||||
$cache = json_decode(file_get_contents($cacheFile), true) ?: [];
|
$cache = json_decode(file_get_contents($cacheFile), true) ?: [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($cache[$cacheKey]['days'])) {
|
if (isset($cache[$cacheKey]['days'])) {
|
||||||
$days = (int)$cache[$cacheKey]['days'];
|
$days = (int)$cache[$cacheKey]['days'];
|
||||||
return $vacuumSealed ? (int)round($days * 1.5) : $days;
|
return $vacuumSealed ? (int)round($days * 1.5) : $days;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Gemini AI
|
// Try Gemini AI (only when explicitly allowed — NOT during bulk stats loops)
|
||||||
$apiKey = env('GEMINI_API_KEY');
|
$apiKey = env('GEMINI_API_KEY');
|
||||||
$days = 0;
|
$days = 0;
|
||||||
if (!empty($apiKey)) {
|
if ($allowAI && !empty($apiKey)) {
|
||||||
$locLabel = match($location) {
|
$locLabel = match($location) {
|
||||||
'frigo' => 'refrigerator (4 °C / 39 °F)',
|
'frigo' => 'refrigerator (4 °C / 39 °F)',
|
||||||
'freezer' => 'freezer (-18 °C / 0 °F)',
|
'freezer' => 'freezer (-18 °C / 0 °F)',
|
||||||
@@ -2324,8 +2389,10 @@ function getOpenedShelfLifeDays(string $name, string $category, string $location
|
|||||||
$source = 'ai';
|
$source = 'ai';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist to cache
|
// Persist to in-memory cache (file will be flushed at end of request via register_shutdown_function)
|
||||||
$cache[$cacheKey] = ['days' => $days, 'source' => $source, 'name' => $name, 'location' => $location, 'ts' => time()];
|
$cache[$cacheKey] = ['days' => $days, 'source' => $source, 'name' => $name, 'location' => $location, 'ts' => time()];
|
||||||
|
$cacheDirty = true;
|
||||||
|
// Write immediately so single-item requests (opened_shelf_life action) are persisted
|
||||||
@file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
@file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
return $vacuumSealed ? (int)round($days * 1.5) : $days;
|
return $vacuumSealed ? (int)round($days * 1.5) : $days;
|
||||||
@@ -3045,7 +3112,7 @@ function generateRecipe(PDO $db): void {
|
|||||||
|
|
||||||
$extraRulesText = '';
|
$extraRulesText = '';
|
||||||
if (!empty($extraRules)) {
|
if (!empty($extraRules)) {
|
||||||
$extraRulesText = "\n\nPREFERENZE DELL'UTENTE:\n" . implode("\n", $extraRules);
|
$extraRulesText = "\n\n⚠️ PREFERENZE OBBLIGATORIE (RISPETTALE SEMPRE, non sono suggerimenti):\n" . implode("\n", array_map(fn($r) => "→ $r", $extraRules));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appliances
|
// Appliances
|
||||||
@@ -3189,7 +3256,7 @@ You are an expert home chef. Generate ONE recipe for $mealLabel for $persons per
|
|||||||
REGOLE:
|
REGOLE:
|
||||||
{$mealPlanRule}1. PRIORITÀ: usa prima gli ingredienti scaduti/in scadenza (⚠️🔴🟠), poi quelli [APERTO], poi il resto.
|
{$mealPlanRule}1. PRIORITÀ: usa prima gli ingredienti scaduti/in scadenza (⚠️🔴🟠), poi quelli [APERTO], poi il resto.
|
||||||
2. Usa SOLO ingredienti dalla lista + acqua/sale/pepe/olio (sempre disponibili).
|
2. Usa SOLO ingredienti dalla lista + acqua/sale/pepe/olio (sempre disponibili).
|
||||||
3. Quantità per $persons persona/e. Se un ingrediente ha poca quantità, usalo TUTTO.
|
3. Quantità MASSIME per $persons persona/e (NON superare mai): pasta/riso asciutto 90g/pers, carne 180g/pers, pesce 200g/pers, legumi secchi 80g/pers (lessi 200g/pers), verdure contorno 200g/pers, formaggio 80g/pers, latte 200ml/pers, farina per dolci 200g/pers. Se un ingrediente rimasto è inferiore a questi limiti, usalo tutto.
|
||||||
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0.
|
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0.
|
||||||
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
||||||
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
||||||
@@ -3722,7 +3789,7 @@ You are an expert home chef. Generate ONE recipe for $mealLabel for $persons per
|
|||||||
REGOLE:
|
REGOLE:
|
||||||
{$mealPlanRule}1. PRIORITÀ: usa prima gli ingredienti scaduti/in scadenza (⚠️🔴🟠), poi quelli [APERTO], poi il resto.
|
{$mealPlanRule}1. PRIORITÀ: usa prima gli ingredienti scaduti/in scadenza (⚠️🔴🟠), poi quelli [APERTO], poi il resto.
|
||||||
2. Usa SOLO ingredienti dalla lista + acqua/sale/pepe/olio (sempre disponibili).
|
2. Usa SOLO ingredienti dalla lista + acqua/sale/pepe/olio (sempre disponibili).
|
||||||
3. Quantità per $persons persona/e. Se un ingrediente ha poca quantità, usalo TUTTO.
|
3. Quantità MASSIME per $persons persona/e (NON superare mai): pasta/riso asciutto 90g/pers, carne 180g/pers, pesce 200g/pers, legumi secchi 80g/pers (lessi 200g/pers), verdure contorno 200g/pers, formaggio 80g/pers, latte 200ml/pers, farina per dolci 200g/pers. Se un ingrediente rimasto è inferiore a questi limiti, usalo tutto.
|
||||||
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0.
|
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0.
|
||||||
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
||||||
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
||||||
@@ -5378,13 +5445,19 @@ function smartShopping(PDO $db): void {
|
|||||||
$need14 = $dailyRate * 14;
|
$need14 = $dailyRate * 14;
|
||||||
|
|
||||||
if ($unit === 'conf') {
|
if ($unit === 'conf') {
|
||||||
// dailyRate already in conf/day
|
// Guard against unit mismatch: transactions may have been recorded in g/ml
|
||||||
$suggestedQty = (int) max(1, min(20, (int)($need14 + 0.3)));
|
// (e.g. product unit was changed from 'g' to 'conf' after initial tracking).
|
||||||
|
// If totalUsed is much larger than buy_count (e.g. 900 vs 4), it's clearly grams.
|
||||||
|
// In that case fall back to purchase-frequency as the daily rate.
|
||||||
|
if ($buyCount > 0 && $totalUsed > $buyCount * 5 && $daysSinceFirst < 999) {
|
||||||
|
$need14 = ($buyCount / $daysSinceFirst) * 14;
|
||||||
|
}
|
||||||
|
$suggestedQty = (int) max(1, min(10, (int)($need14 + 0.3)));
|
||||||
$suggestedUnit = 'conf';
|
$suggestedUnit = 'conf';
|
||||||
|
|
||||||
} elseif ($pkgUnit !== '' && $defQty > 0) {
|
} elseif ($pkgUnit !== '' && $defQty > 0) {
|
||||||
// Real package info available → express in confezioni (definitive)
|
// Real package info available → express in confezioni (definitive)
|
||||||
$pkgs = (int) max(1, min(20, (int)($need14 / $defQty + 0.3)));
|
$pkgs = (int) max(1, min(10, (int)($need14 / $defQty + 0.3)));
|
||||||
$suggestedQty = $pkgs;
|
$suggestedQty = $pkgs;
|
||||||
$suggestedUnit = 'conf';
|
$suggestedUnit = 'conf';
|
||||||
|
|
||||||
@@ -5393,7 +5466,7 @@ function smartShopping(PDO $db): void {
|
|||||||
// use defQty as the minimum purchase unit and round to nearest multiple.
|
// use defQty as the minimum purchase unit and round to nearest multiple.
|
||||||
// This ensures we never suggest less than one "reference pack".
|
// This ensures we never suggest less than one "reference pack".
|
||||||
$pkgs = (int) max(1, (int)($need14 / $defQty + 0.3));
|
$pkgs = (int) max(1, (int)($need14 / $defQty + 0.3));
|
||||||
$pkgs = min(20, $pkgs);
|
$pkgs = min(10, $pkgs);
|
||||||
$suggestedQty = $pkgs * (int)$defQty;
|
$suggestedQty = $pkgs * (int)$defQty;
|
||||||
$suggestedUnit = $unit;
|
$suggestedUnit = $unit;
|
||||||
$suggestedApprox = true; // always "almeno" — no confirmed pkg size
|
$suggestedApprox = true; // always "almeno" — no confirmed pkg size
|
||||||
@@ -5416,7 +5489,7 @@ function smartShopping(PDO $db): void {
|
|||||||
|
|
||||||
} elseif ($unit === 'pz') {
|
} elseif ($unit === 'pz') {
|
||||||
// No package info → raw pz count, approximate
|
// No package info → raw pz count, approximate
|
||||||
$suggestedQty = (int) max(1, min(20, (int)($need14 + 0.3)));
|
$suggestedQty = (int) max(1, min(10, (int)($need14 + 0.3)));
|
||||||
$suggestedUnit = 'pz';
|
$suggestedUnit = 'pz';
|
||||||
$suggestedApprox = ($suggestedQty > 1);
|
$suggestedApprox = ($suggestedQty > 1);
|
||||||
}
|
}
|
||||||
@@ -6389,3 +6462,328 @@ function geminiAnomalyExplain(): void {
|
|||||||
|
|
||||||
echo json_encode(['success' => true, 'explanation' => $explanation]);
|
echo json_encode(['success' => true, 'explanation' => $explanation]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SHOPPING LIST PRICE ESTIMATION (AI-powered, cached)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Note: PRICE_CACHE_PATH constant is defined at the top of the file.
|
||||||
|
|
||||||
|
function _loadPriceCache(): array {
|
||||||
|
if (!file_exists(PRICE_CACHE_PATH)) return [];
|
||||||
|
try { return json_decode(file_get_contents(PRICE_CACHE_PATH), true) ?? []; } catch (\Throwable $e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _savePriceCache(array $data): void {
|
||||||
|
file_put_contents(PRICE_CACHE_PATH, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return cache key: md5(lowercase name + country + schema version)
|
||||||
|
* Bump version suffix when AI prompt format changes to auto-invalidate old entries.
|
||||||
|
*/
|
||||||
|
function _priceKey(string $name, string $country): string {
|
||||||
|
return md5(mb_strtolower(trim($name)) . '|' . mb_strtolower(trim($country)) . '|v2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask Gemini for the estimated retail price per unit (kg, l, pz as appropriate)
|
||||||
|
* for a product in a given country/currency. Returns an array:
|
||||||
|
* { price_per_unit, unit_label, currency, source_note } or null on failure.
|
||||||
|
*/
|
||||||
|
function _fetchPriceFromAI(string $name, string $country, string $currency, string $lang): ?array {
|
||||||
|
$apiKey = env('GEMINI_API_KEY');
|
||||||
|
if (empty($apiKey)) return null;
|
||||||
|
|
||||||
|
$langLabel = match($lang) { 'en' => 'English', 'de' => 'German', default => 'Italian' };
|
||||||
|
|
||||||
|
$prompt = <<<PROMPT
|
||||||
|
You are a grocery price assistant. Estimate the typical retail price for "{$name}" in {$country}, currency {$currency}.
|
||||||
|
|
||||||
|
Return the price for the MOST NATURAL RETAIL UNIT — that is, the smallest standard unit a shopper would actually buy:
|
||||||
|
- Products in standard packages (pasta, flour, frozen food, yogurt, canned goods): price per typical package (e.g. "pacco 500g", "barattolo 400g", "confezione")
|
||||||
|
- Products sold by the piece or bunch (fresh herbs, eggs, individual fruits/vegetables, single portions): price per piece/bunch (e.g. "mazzo", "uovo", "pz")
|
||||||
|
- Liquids in bottles or cartons: price per typical container (e.g. "bottiglia 1L", "brick 1L")
|
||||||
|
- Deli counter items sold loose by weight (prosciutto, salami, fresh fish): price per kg
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Use mid-range supermarket prices (not premium, not discount).
|
||||||
|
2. Round to 2 decimal places.
|
||||||
|
3. NEVER return per-kg for items normally sold in packages or by the piece.
|
||||||
|
4. Respond ONLY with valid JSON — no markdown, no explanation:
|
||||||
|
{"price_per_unit": 1.50, "unit_label": "mazzo", "currency": "{$currency}", "source_note": "Basilico fresco ~€1.50/mazzo in {$country}"}
|
||||||
|
|
||||||
|
If truly unknown, return: {"price_per_unit": null, "unit_label": null, "currency": "{$currency}", "source_note": "prezzo non disponibile"}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$payload = ['contents' => [['parts' => [['text' => $prompt]]]]];
|
||||||
|
$result = callGeminiWithFallback($apiKey, $payload, 20);
|
||||||
|
|
||||||
|
if ($result['http_code'] !== 200) return null;
|
||||||
|
|
||||||
|
$text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||||
|
$text = preg_replace('/^```json\s*/i', '', $text);
|
||||||
|
$text = preg_replace('/\s*```$/i', '', $text);
|
||||||
|
$data = json_decode(trim($text), true);
|
||||||
|
|
||||||
|
if (!$data || !isset($data['price_per_unit'])) return null;
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/?action=get_shopping_price
|
||||||
|
* POST body: { name, quantity, unit, default_quantity, package_unit, country, currency, lang, force_refresh }
|
||||||
|
*
|
||||||
|
* Returns: { success, name, price_per_unit, unit_label, currency, estimated_total, estimated_total_label, cached_at, source_note }
|
||||||
|
*/
|
||||||
|
function getShoppingPrice(PDO $db): void {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$name = trim($input['name'] ?? '');
|
||||||
|
$qty = (float)($input['quantity'] ?? 1);
|
||||||
|
$unit = trim($input['unit'] ?? 'pz');
|
||||||
|
$defQty = (float)($input['default_quantity'] ?? 0);
|
||||||
|
$pkgUnit = trim($input['package_unit'] ?? '');
|
||||||
|
$country = trim($input['country'] ?? env('PRICE_COUNTRY', 'Italia'));
|
||||||
|
$currency= trim($input['currency'] ?? env('PRICE_CURRENCY', 'EUR'));
|
||||||
|
$lang = trim($input['lang'] ?? 'it');
|
||||||
|
$forceRefresh = !empty($input['force_refresh']);
|
||||||
|
$updateMonths = (int)env('PRICE_UPDATE_MONTHS', '3');
|
||||||
|
|
||||||
|
if (empty($name)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'missing name']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = _loadPriceCache();
|
||||||
|
$key = _priceKey($name, $country);
|
||||||
|
$now = time();
|
||||||
|
$maxAge = $updateMonths * 30 * 86400;
|
||||||
|
|
||||||
|
// Use cache if fresh
|
||||||
|
if (!$forceRefresh && isset($cache[$key])) {
|
||||||
|
$entry = $cache[$key];
|
||||||
|
$age = $now - ($entry['cached_at'] ?? 0);
|
||||||
|
if ($age < $maxAge) {
|
||||||
|
$entry['success'] = true;
|
||||||
|
$entry['from_cache'] = true;
|
||||||
|
$entry['estimated_total'] = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $qty, $unit, $defQty, $pkgUnit);
|
||||||
|
$entry['estimated_total_label'] = _formatPrice($entry['estimated_total'], $currency);
|
||||||
|
echo json_encode($entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceData = _fetchPriceFromAI($name, $country, $currency, $lang);
|
||||||
|
if (!$priceData || $priceData['price_per_unit'] === null) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'price_not_found', 'name' => $name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'name' => $name,
|
||||||
|
'price_per_unit'=> (float)$priceData['price_per_unit'],
|
||||||
|
'unit_label' => $priceData['unit_label'] ?? 'kg',
|
||||||
|
'currency' => $currency,
|
||||||
|
'source_note' => $priceData['source_note'] ?? '',
|
||||||
|
'country' => $country,
|
||||||
|
'cached_at' => $now,
|
||||||
|
];
|
||||||
|
$cache[$key] = $entry;
|
||||||
|
_savePriceCache($cache);
|
||||||
|
|
||||||
|
$entry['success'] = true;
|
||||||
|
$entry['from_cache'] = false;
|
||||||
|
$entry['estimated_total'] = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $qty, $unit, $defQty, $pkgUnit);
|
||||||
|
$entry['estimated_total_label'] = _formatPrice($entry['estimated_total'], $currency);
|
||||||
|
echo json_encode($entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/?action=get_all_shopping_prices
|
||||||
|
* POST body: { items: [{name, quantity, unit, default_quantity, package_unit}], country, currency, lang, force_refresh }
|
||||||
|
*
|
||||||
|
* Returns: { success, prices: { name → priceEntry }, total, total_label }
|
||||||
|
*/
|
||||||
|
function getAllShoppingPrices(PDO $db): void {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$items = $input['items'] ?? [];
|
||||||
|
$country = trim($input['country'] ?? env('PRICE_COUNTRY', 'Italia'));
|
||||||
|
$currency= trim($input['currency'] ?? env('PRICE_CURRENCY', 'EUR'));
|
||||||
|
$lang = trim($input['lang'] ?? 'it');
|
||||||
|
$forceRefresh = !empty($input['force_refresh']);
|
||||||
|
$updateMonths = (int)env('PRICE_UPDATE_MONTHS', '3');
|
||||||
|
|
||||||
|
if (empty($items)) {
|
||||||
|
echo json_encode(['success' => true, 'prices' => [], 'total' => 0, 'total_label' => _formatPrice(0, $currency)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = _loadPriceCache();
|
||||||
|
$now = time();
|
||||||
|
$maxAge = $updateMonths * 30 * 86400;
|
||||||
|
$prices = [];
|
||||||
|
$total = 0.0;
|
||||||
|
$missing = [];
|
||||||
|
|
||||||
|
// First pass: serve from cache
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$name = trim($item['name'] ?? '');
|
||||||
|
$qty = (float)($item['quantity'] ?? 1);
|
||||||
|
$unit = trim($item['unit'] ?? 'pz');
|
||||||
|
$defQty = (float)($item['default_quantity'] ?? 0);
|
||||||
|
$pkgUnit = trim($item['package_unit'] ?? '');
|
||||||
|
if (empty($name)) continue;
|
||||||
|
|
||||||
|
$key = _priceKey($name, $country);
|
||||||
|
if (!$forceRefresh && isset($cache[$key])) {
|
||||||
|
$age = $now - ($cache[$key]['cached_at'] ?? 0);
|
||||||
|
if ($age < $maxAge) {
|
||||||
|
$entry = $cache[$key];
|
||||||
|
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $qty, $unit, $defQty, $pkgUnit);
|
||||||
|
$prices[$name] = array_merge($entry, [
|
||||||
|
'estimated_total' => $est,
|
||||||
|
'estimated_total_label' => _formatPrice($est, $currency),
|
||||||
|
'from_cache' => true,
|
||||||
|
]);
|
||||||
|
$total += $est ?? 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$missing[] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: fetch missing from AI (sequential to avoid rate limits)
|
||||||
|
foreach ($missing as $item) {
|
||||||
|
$name = trim($item['name'] ?? '');
|
||||||
|
$qty = (float)($item['quantity'] ?? 1);
|
||||||
|
$unit = trim($item['unit'] ?? 'pz');
|
||||||
|
$defQty = (float)($item['default_quantity'] ?? 0);
|
||||||
|
$pkgUnit = trim($item['package_unit'] ?? '');
|
||||||
|
$key = _priceKey($name, $country);
|
||||||
|
|
||||||
|
$priceData = _fetchPriceFromAI($name, $country, $currency, $lang);
|
||||||
|
if ($priceData && $priceData['price_per_unit'] !== null) {
|
||||||
|
$entry = [
|
||||||
|
'name' => $name,
|
||||||
|
'price_per_unit'=> (float)$priceData['price_per_unit'],
|
||||||
|
'unit_label' => $priceData['unit_label'] ?? 'kg',
|
||||||
|
'currency' => $currency,
|
||||||
|
'source_note' => $priceData['source_note'] ?? '',
|
||||||
|
'country' => $country,
|
||||||
|
'cached_at' => $now,
|
||||||
|
];
|
||||||
|
$cache[$key] = $entry;
|
||||||
|
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $qty, $unit, $defQty, $pkgUnit);
|
||||||
|
$prices[$name] = array_merge($entry, [
|
||||||
|
'estimated_total' => $est,
|
||||||
|
'estimated_total_label' => _formatPrice($est, $currency),
|
||||||
|
'from_cache' => false,
|
||||||
|
]);
|
||||||
|
$total += $est ?? 0;
|
||||||
|
} else {
|
||||||
|
$prices[$name] = ['name' => $name, 'error' => 'not_found', 'estimated_total' => null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_savePriceCache($cache);
|
||||||
|
|
||||||
|
$total = round($total, 2);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'prices' => $prices,
|
||||||
|
'total' => $total,
|
||||||
|
'total_label' => _formatPrice($total, $currency),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate estimated cost for a shopping item given price_per_unit and the item's quantity/unit.
|
||||||
|
* Price unit: kg, l, pz/unit
|
||||||
|
*/
|
||||||
|
function _calcEstimatedTotal(float $pricePerUnit, string $priceUnitLabel, float $qty, string $unit, float $defQty, string $pkgUnit): ?float {
|
||||||
|
if ($pricePerUnit <= 0) return null;
|
||||||
|
|
||||||
|
$label = strtolower(trim($priceUnitLabel));
|
||||||
|
|
||||||
|
// ── Weight-based price (per kg) ───────────────────────────────────────────
|
||||||
|
// Only exact 'kg' triggers weight conversion; retail-unit labels like
|
||||||
|
// "pacco 500g" or "mazzo" fall through to the countable path below.
|
||||||
|
if ($label === 'kg') {
|
||||||
|
$weightKg = 0.0;
|
||||||
|
if ($unit === 'conf' && $defQty > 0 && !empty($pkgUnit)) {
|
||||||
|
$sub = strtolower($pkgUnit);
|
||||||
|
if ($sub === 'g') $weightKg = $qty * $defQty / 1000.0;
|
||||||
|
elseif ($sub === 'kg') $weightKg = $qty * $defQty;
|
||||||
|
// unknown sub-unit: can't convert → return null
|
||||||
|
} elseif ($unit === 'g') {
|
||||||
|
$weightKg = $qty / 1000.0;
|
||||||
|
} elseif ($unit === 'kg') {
|
||||||
|
$weightKg = $qty;
|
||||||
|
}
|
||||||
|
// pz / conf without defQty → we don't know the weight → no total
|
||||||
|
if ($weightKg <= 0) return null;
|
||||||
|
return round($pricePerUnit * $weightKg, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Volume-based price (per liter) ────────────────────────────────────────
|
||||||
|
if (in_array($label, ['l', 'lt', 'litre', 'liter', 'litro'])) {
|
||||||
|
$volumeL = 0.0;
|
||||||
|
if ($unit === 'conf' && $defQty > 0 && !empty($pkgUnit)) {
|
||||||
|
$sub = strtolower($pkgUnit);
|
||||||
|
if ($sub === 'ml') $volumeL = $qty * $defQty / 1000.0;
|
||||||
|
elseif ($sub === 'l') $volumeL = $qty * $defQty;
|
||||||
|
} elseif ($unit === 'ml') {
|
||||||
|
$volumeL = $qty / 1000.0;
|
||||||
|
} elseif ($unit === 'l') {
|
||||||
|
$volumeL = $qty;
|
||||||
|
}
|
||||||
|
if ($volumeL <= 0) return null;
|
||||||
|
return round($pricePerUnit * $volumeL, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Countable retail unit (mazzo, pacco, barattolo, pz, conf, …) ─────────
|
||||||
|
// price_per_unit is already the price for ONE retail unit.
|
||||||
|
//
|
||||||
|
// Special case: shopping qty is in g/ml but price is per-package.
|
||||||
|
// We must convert grams→packages so we don't multiply 100×€2.75=€275.
|
||||||
|
if (in_array(strtolower($unit), ['g', 'ml'])) {
|
||||||
|
$pkgWeight = 0.0;
|
||||||
|
// 1) Use defQty if package unit matches (e.g. defQty=250, pkgUnit='g', unit='g')
|
||||||
|
if ($defQty > 0 && !empty($pkgUnit) && strtolower($pkgUnit) === strtolower($unit)) {
|
||||||
|
$pkgWeight = $defQty;
|
||||||
|
}
|
||||||
|
// 2) Extract weight from label: "confezione 250g", "vasetto 125ml", "pacco 500g"
|
||||||
|
if ($pkgWeight <= 0) {
|
||||||
|
if (preg_match('/\b(\d+(?:[.,]\d+)?)\s*(g|ml)\b/i', $priceUnitLabel, $m)) {
|
||||||
|
if (strtolower($m[2]) === strtolower($unit)) {
|
||||||
|
$pkgWeight = (float)str_replace(',', '.', $m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3) Also try defQty alone (no pkgUnit set but defQty likely in same unit)
|
||||||
|
if ($pkgWeight <= 0 && $defQty > 0) {
|
||||||
|
$pkgWeight = $defQty;
|
||||||
|
}
|
||||||
|
if ($pkgWeight > 0) {
|
||||||
|
$packages = (int) max(1, ceil($qty / $pkgWeight));
|
||||||
|
return round($pricePerUnit * $packages, 2);
|
||||||
|
}
|
||||||
|
// No conversion possible → return single-unit price (1 package minimum)
|
||||||
|
return round($pricePerUnit, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$buyQty = max(1.0, $qty);
|
||||||
|
return round($pricePerUnit * $buyQty, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatPrice(float $amount, string $currency): string {
|
||||||
|
$sym = match(strtoupper($currency)) {
|
||||||
|
'EUR' => '€', 'USD' => '$', 'GBP' => '£', 'CHF' => 'CHF',
|
||||||
|
'JPY' => '¥', 'CNY' => '¥', 'CAD' => 'CA$', 'AUD' => 'A$',
|
||||||
|
'BRL' => 'R$', 'RUB' => '₽', 'INR' => '₹', 'MXN' => '$',
|
||||||
|
'SEK' => 'kr', 'NOK' => 'kr', 'DKK' => 'kr', 'PLN' => 'zł',
|
||||||
|
'CZK' => 'Kč', 'HUF' => 'Ft', 'RON' => 'lei',
|
||||||
|
default => $currency,
|
||||||
|
};
|
||||||
|
return $sym . number_format($amount, 2, '.', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -516,6 +516,24 @@ body {
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skeleton shimmer while stat card data is loading */
|
||||||
|
@keyframes stat-shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.stat-value.stat-loading {
|
||||||
|
color: transparent !important;
|
||||||
|
background: linear-gradient(90deg, var(--border) 25%, color-mix(in srgb, var(--border) 40%, white) 50%, var(--border) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: stat-shimmer 1.2s ease-in-out infinite;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 2.4rem;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
@@ -1861,6 +1879,121 @@ body {
|
|||||||
.badge-local-tag { background: #e0f2fe; color: #0369a1; cursor: pointer; }
|
.badge-local-tag { background: #e0f2fe; color: #0369a1; cursor: pointer; }
|
||||||
.badge-local-tag:hover { background: #bae6fd; }
|
.badge-local-tag:hover { background: #bae6fd; }
|
||||||
|
|
||||||
|
/* ─── Shopping price badge ─── */
|
||||||
|
.shopping-item-price-badge {
|
||||||
|
margin-top: 4px;
|
||||||
|
min-height: 1.2rem;
|
||||||
|
}
|
||||||
|
.price-badge-value {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #15803d;
|
||||||
|
background: #dcfce7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.price-badge-loading {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.price-badge-error {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Price column — right-side per-item price display ─── */
|
||||||
|
.shopping-item-price-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 58px;
|
||||||
|
text-align: right;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.price-col-main {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #15803d;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.price-col-unit {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.price-col-loading {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.price-col-error {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Price summary bar (top of shopping tab) ─── */
|
||||||
|
.shopping-price-total-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #166534;
|
||||||
|
box-shadow: 0 1px 4px rgba(21,128,61,0.08);
|
||||||
|
}
|
||||||
|
.price-total-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.price-total-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.btn-price-refresh {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-price-refresh:hover { background: rgba(0,0,0,0.07); }
|
||||||
|
.btn-price-refresh:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
/* Loading progress bar */
|
||||||
|
.price-loading-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: #dcfce7;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0 12px 6px;
|
||||||
|
}
|
||||||
|
.price-loading-inner {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: #16a34a;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
@keyframes price-sweep {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(350%); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag add button */
|
/* Tag add button */
|
||||||
.shopping-item-tag-btn {
|
.shopping-item-tag-btn {
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
+531
-39
@@ -1258,34 +1258,35 @@ function guessCategoryFromName(name) {
|
|||||||
if (!name) return 'altro';
|
if (!name) return 'altro';
|
||||||
const n = name.toLowerCase();
|
const n = name.toLowerCase();
|
||||||
// Pasta & Rice
|
// Pasta & Rice
|
||||||
if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio/.test(n)) return 'pasta';
|
if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio|gnocchi|lasagne|tagliatelle|maccheroni|bucatini|pennette/.test(n)) return 'pasta';
|
||||||
// Pane & Forno
|
// Pane & Forno
|
||||||
if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli/.test(n)) return 'pane';
|
if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli|pancarrè|baguette|ciabatta|rosetta|tramezzino|tortilla|pita\b/.test(n)) return 'pane';
|
||||||
|
// Latticini (before bevande to avoid latte→bevande)
|
||||||
|
if (/latte\b|yogurt|yaourt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg\b|burrata|scamorza|provolone|pecorino|fontina|taleggio|stracchino|crescenza|brie|camembert|emmental|asiago|feta\b|provola|caciotta|caprino/.test(n)) return 'latticini';
|
||||||
// Conserve
|
// Conserve
|
||||||
if (/passata|pelati|pomodoro|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive/.test(n)) return 'conserve';
|
if (/passata|pelati|pomodoro|pomodori|pomodorini|ciliegino|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive|tonno in scatola|sgombro in scatola|concentrato|brodo|dado|besciamella/.test(n)) return 'conserve';
|
||||||
// Condimenti (include spezie, farine, zucchero)
|
// Condimenti (include spezie, farine, zucchero)
|
||||||
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa|paprika|curry|cannella|noce moscata|origano|rosmarino|timo|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido|maizena|semola/.test(n)) return 'condimenti';
|
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa|paprika|curry|cannella|noce moscata|origano|rosmarino|timo|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido|maizena|semola|pesto|tahini|miso\b|colatura|soia.*salsa|worcester|tabasco/.test(n)) return 'condimenti';
|
||||||
// Bevande
|
// Bevande (after latticini to avoid latte conflict)
|
||||||
if (/acqua|birra|vino|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|latte\b/.test(n)) return 'bevande';
|
if (/acqua\b|birra\b|vino\b|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|tisana|camomilla|infuso|energy drink|bevanda|limonata|aranciate|sprite|pepsi|fanta|san pellegrino/.test(n)) return 'bevande';
|
||||||
// Latticini (include eggs/uova)
|
|
||||||
if (/latte\b|yogurt|yaourt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg/.test(n)) return 'latticini';
|
|
||||||
// Carne (include salumi)
|
// Carne (include salumi)
|
||||||
if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck|pancetta|nduja|guanciale|cotechino|salsiccia/.test(n)) return 'carne';
|
if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck|pancetta|nduja|guanciale|cotechino|salsiccia|agnello|cinghiale|polpette|arrosto|bistecca|cotoletta|lonza|braciola/.test(n)) return 'carne';
|
||||||
// Pesce
|
// Pesce
|
||||||
if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe|baccalà|vongole|cozze|calamari|surimi/.test(n)) return 'pesce';
|
if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe|baccalà|vongole|cozze|calamari|surimi|alici|branzino|orata|sardine|trota|dentice|seppia|polpo/.test(n)) return 'pesce';
|
||||||
// Frutta
|
// Frutta
|
||||||
if (/mela|mele|banana|arancia|pera|fragola|uva|kiwi|limone|frutta|mandarino|clementina|pompelmo|avocado|mango|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca|nettarina|fico|melograno/.test(n)) return 'frutta';
|
if (/mela|mele|banana|arancia|pera|fragola|uva\b|kiwi|limone|frutta|mandarino|clementina|pompelmo|avocado|mango|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca\b|nettarina|fico\b|melograno|papaya|maracuja|cocco\b|dattero|fico\b|lampone|mirtillo|ribes|more\b/.test(n)) return 'frutta';
|
||||||
// Verdura
|
// Verdura
|
||||||
if (/insalata|zucchina|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata|finocchio|sedano|porro|scalogno|cavolo|cavolfiore|asparagi|funghi|courgette|lattuga|bietola|radicchio|carciofo|fagiolini|piselli|mais|zucca|aglio/.test(n)) return 'verdura';
|
if (/insalata|zucchina|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata|finocchio|sedano|porro|scalogno|cavolo|cavolfiore|asparagi|funghi|courgette|lattuga|bietola|radicchio|carciofo|fagiolini|piselli|mais|zucca|aglio|cetriolo|rapa|barbabietola|cime di rapa|pak choi|bok choy|verza|cavolo nero/.test(n)) return 'verdura';
|
||||||
// Surgelati
|
// Surgelati
|
||||||
if (/surgelat|frozen|findus|4.salti|gelato/.test(n)) return 'surgelati';
|
if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato/.test(n)) return 'surgelati';
|
||||||
// Snack
|
// Snack & Dolci
|
||||||
if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini/.test(n)) return 'snack';
|
if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini|torta|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramisù/.test(n)) return 'snack';
|
||||||
// Cereali
|
// Cereali
|
||||||
if (/cereali|muesli|fiocchi|granola|polenta/.test(n)) return 'cereali';
|
if (/cereali|muesli|fiocchi|granola|polenta|porridge|avena/.test(n)) return 'cereali';
|
||||||
// Igiene / Pulizia
|
// Igiene personale
|
||||||
if (/sapone|shampoo|dentifricio|deodorante/.test(n)) return 'igiene';
|
if (/sapone|shampoo|dentifricio|deodorante|carta igienica|fazzoletti|cotton fioc|assorbente|rasoio|schiuma da barba|gel doccia|balsamo\b|lozione/.test(n)) return 'igiene';
|
||||||
if (/detersivo|pulito|sgrassatore/.test(n)) return 'pulizia';
|
// Pulizia casa
|
||||||
|
if (/detersivo|pulito|sgrassatore|candeggina|ammorbidente|anticalcare|bucato|piatti|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia';
|
||||||
return 'altro';
|
return 'altro';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1871,8 +1872,25 @@ function debounce(fn, ms) {
|
|||||||
|
|
||||||
async function syncSettingsFromDB() {
|
async function syncSettingsFromDB() {
|
||||||
try {
|
try {
|
||||||
// Primary: load from server .env
|
// Primary: load from server .env (only when not already done via _applySyncedSettings)
|
||||||
const serverSettings = await api('get_settings');
|
const serverSettings = await api('get_settings');
|
||||||
|
_applySyncedSettings(serverSettings);
|
||||||
|
// Also load review_confirmed from DB
|
||||||
|
const res = await api('app_settings_get');
|
||||||
|
if (res.success && res.settings) {
|
||||||
|
if (res.settings.review_confirmed) {
|
||||||
|
_reviewConfirmedCache = res.settings.review_confirmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) { /* offline, use local */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply server settings object into localStorage cache.
|
||||||
|
* Called both from _initApp (to reuse an already-fetched response) and syncSettingsFromDB.
|
||||||
|
*/
|
||||||
|
function _applySyncedSettings(serverSettings) {
|
||||||
|
if (!serverSettings) return;
|
||||||
_geminiAvailable = !!(serverSettings.gemini_key_set);
|
_geminiAvailable = !!(serverSettings.gemini_key_set);
|
||||||
_demoMode = !!serverSettings.demo_mode;
|
_demoMode = !!serverSettings.demo_mode;
|
||||||
_updateGeminiButtonState();
|
_updateGeminiButtonState();
|
||||||
@@ -1883,23 +1901,20 @@ async function syncSettingsFromDB() {
|
|||||||
'camera_facing','scale_enabled','scale_gateway_url',
|
'camera_facing','scale_enabled','scale_gateway_url',
|
||||||
'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',
|
||||||
'screensaver_enabled','screensaver_timeout'];
|
'screensaver_enabled','screensaver_timeout',
|
||||||
|
'price_enabled','price_country','price_currency','price_update_months'];
|
||||||
|
let changed = false;
|
||||||
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] !== '') {
|
||||||
s[key] = serverSettings[key];
|
s[key] = serverSettings[key];
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changed) {
|
||||||
_settingsCache = s;
|
_settingsCache = s;
|
||||||
localStorage.setItem('evershelf_settings', JSON.stringify(s));
|
localStorage.setItem('evershelf_settings', JSON.stringify(s));
|
||||||
// Also load review_confirmed from DB
|
|
||||||
const res = await api('app_settings_get');
|
|
||||||
if (res.success && res.settings) {
|
|
||||||
if (res.settings.review_confirmed) {
|
|
||||||
_reviewConfirmedCache = res.settings.review_confirmed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) { /* offline, use local */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSettingsUI() {
|
async function loadSettingsUI() {
|
||||||
const s = getSettings();
|
const s = getSettings();
|
||||||
@@ -1993,7 +2008,8 @@ async function loadSettingsUI() {
|
|||||||
'camera_facing','scale_enabled','scale_gateway_url',
|
'camera_facing','scale_enabled','scale_gateway_url',
|
||||||
'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',
|
||||||
|
'price_enabled','price_country','price_currency','price_update_months'];
|
||||||
// Note: gemini_key is never sent from server; settings_token_set is metadata only
|
// Note: gemini_key is never sent from server; settings_token_set is metadata only
|
||||||
const settingsTokenRequired = !!serverSettings.settings_token_set;
|
const settingsTokenRequired = !!serverSettings.settings_token_set;
|
||||||
const tokenHintEl = document.getElementById('settings-token-status-hint');
|
const tokenHintEl = document.getElementById('settings-token-status-hint');
|
||||||
@@ -2035,8 +2051,34 @@ async function loadSettingsUI() {
|
|||||||
if (mpEnabledEl) mpEnabledEl.checked = mpEnabledUp;
|
if (mpEnabledEl) mpEnabledEl.checked = mpEnabledUp;
|
||||||
if (mpConfigSection) mpConfigSection.style.display = mpEnabledUp ? '' : 'none';
|
if (mpConfigSection) mpConfigSection.style.display = mpEnabledUp ? '' : 'none';
|
||||||
if (mpLegendCard) mpLegendCard.style.display = mpEnabledUp ? '' : 'none';
|
if (mpLegendCard) mpLegendCard.style.display = mpEnabledUp ? '' : 'none';
|
||||||
|
// Price settings (server merge)
|
||||||
|
if (priceEnabledEl) {
|
||||||
|
priceEnabledEl.checked = !!s.price_enabled;
|
||||||
|
const pSub = document.getElementById('price-settings-sub');
|
||||||
|
if (pSub) pSub.style.display = s.price_enabled ? '' : 'none';
|
||||||
|
}
|
||||||
|
if (priceCountryEl) priceCountryEl.value = s.price_country || 'Italia';
|
||||||
|
if (priceCurrencyEl) priceCurrencyEl.value = s.price_currency || 'EUR';
|
||||||
|
if (priceMonthsEl) priceMonthsEl.value = s.price_update_months || 3;
|
||||||
}
|
}
|
||||||
} catch(e) { /* offline, use local */ }
|
} catch(e) { /* offline, use local */ }
|
||||||
|
// Price settings
|
||||||
|
const priceEnabledEl = document.getElementById('setting-price-enabled');
|
||||||
|
if (priceEnabledEl) {
|
||||||
|
priceEnabledEl.checked = !!s.price_enabled;
|
||||||
|
const priceSubEl = document.getElementById('price-settings-sub');
|
||||||
|
if (priceSubEl) priceSubEl.style.display = s.price_enabled ? '' : 'none';
|
||||||
|
priceEnabledEl.onchange = function() {
|
||||||
|
const sub = document.getElementById('price-settings-sub');
|
||||||
|
if (sub) sub.style.display = this.checked ? '' : 'none';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const priceCountryEl = document.getElementById('setting-price-country');
|
||||||
|
if (priceCountryEl) priceCountryEl.value = s.price_country || 'Italia';
|
||||||
|
const priceCurrencyEl = document.getElementById('setting-price-currency');
|
||||||
|
if (priceCurrencyEl) priceCurrencyEl.value = s.price_currency || 'EUR';
|
||||||
|
const priceMonthsEl = document.getElementById('setting-price-update-months');
|
||||||
|
if (priceMonthsEl) priceMonthsEl.value = s.price_update_months || 3;
|
||||||
// Scale settings
|
// Scale settings
|
||||||
const scaleEnabledUiEl = document.getElementById('setting-scale-enabled');
|
const scaleEnabledUiEl = document.getElementById('setting-scale-enabled');
|
||||||
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
|
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
|
||||||
@@ -2358,6 +2400,15 @@ async function saveSettings() {
|
|||||||
if (scaleEnabledEl) s.scale_enabled = scaleEnabledEl.checked;
|
if (scaleEnabledEl) s.scale_enabled = scaleEnabledEl.checked;
|
||||||
const scaleUrlEl = document.getElementById('setting-scale-url');
|
const scaleUrlEl = document.getElementById('setting-scale-url');
|
||||||
if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim();
|
if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim();
|
||||||
|
// Price settings
|
||||||
|
const priceEnabledSaveEl = document.getElementById('setting-price-enabled');
|
||||||
|
if (priceEnabledSaveEl) s.price_enabled = priceEnabledSaveEl.checked;
|
||||||
|
const priceCountrySaveEl = document.getElementById('setting-price-country');
|
||||||
|
if (priceCountrySaveEl) s.price_country = priceCountrySaveEl.value;
|
||||||
|
const priceCurrencySaveEl = document.getElementById('setting-price-currency');
|
||||||
|
if (priceCurrencySaveEl) s.price_currency = priceCurrencySaveEl.value;
|
||||||
|
const priceMonthsSaveEl = document.getElementById('setting-price-update-months');
|
||||||
|
if (priceMonthsSaveEl) s.price_update_months = parseInt(priceMonthsSaveEl.value, 10) || 3;
|
||||||
saveSettingsToStorage(s);
|
saveSettingsToStorage(s);
|
||||||
|
|
||||||
// Save ALL settings to server .env
|
// Save ALL settings to server .env
|
||||||
@@ -2389,6 +2440,10 @@ async function saveSettings() {
|
|||||||
tts_auth_type: s.tts_auth_type,
|
tts_auth_type: s.tts_auth_type,
|
||||||
tts_content_type: s.tts_content_type,
|
tts_content_type: s.tts_content_type,
|
||||||
tts_payload_key: s.tts_payload_key,
|
tts_payload_key: s.tts_payload_key,
|
||||||
|
price_enabled: s.price_enabled,
|
||||||
|
price_country: s.price_country,
|
||||||
|
price_currency: s.price_currency,
|
||||||
|
price_update_months: s.price_update_months,
|
||||||
}, tokenHeader);
|
}, tokenHeader);
|
||||||
const statusEl = document.getElementById('settings-status');
|
const statusEl = document.getElementById('settings-status');
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -2481,7 +2536,10 @@ function refreshCurrentPage() {
|
|||||||
switch(_currentPageId) {
|
switch(_currentPageId) {
|
||||||
case 'dashboard': loadDashboard(); break;
|
case 'dashboard': loadDashboard(); break;
|
||||||
case 'inventory': loadInventory(); break;
|
case 'inventory': loadInventory(); break;
|
||||||
case 'shopping': loadShoppingList(); break;
|
case 'shopping':
|
||||||
|
loadShoppingList._bgCall = true;
|
||||||
|
loadShoppingList();
|
||||||
|
break;
|
||||||
case 'products': loadAllProducts(); break;
|
case 'products': loadAllProducts(); break;
|
||||||
case 'recipe': loadRecipeArchive(); break;
|
case 'recipe': loadRecipeArchive(); break;
|
||||||
case 'log': loadLog(); break;
|
case 'log': loadLog(); break;
|
||||||
@@ -3117,6 +3175,12 @@ function _applyInsightPhase() {
|
|||||||
|
|
||||||
// ===== DASHBOARD =====
|
// ===== DASHBOARD =====
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
|
// Show shimmer on stat cards while loading
|
||||||
|
['stat-dispensa', 'stat-frigo', 'stat-freezer'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.classList.add('stat-loading');
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [summaryData, statsData] = await Promise.all([
|
const [summaryData, statsData] = await Promise.all([
|
||||||
api('inventory_summary'),
|
api('inventory_summary'),
|
||||||
@@ -3131,6 +3195,7 @@ async function loadDashboard() {
|
|||||||
const count = s ? s.product_count : 0;
|
const count = s ? s.product_count : 0;
|
||||||
const el = document.getElementById(`stat-${loc}`);
|
const el = document.getElementById(`stat-${loc}`);
|
||||||
el.textContent = count;
|
el.textContent = count;
|
||||||
|
el.classList.remove('stat-loading');
|
||||||
total += count;
|
total += count;
|
||||||
});
|
});
|
||||||
// Add non-standard locations
|
// Add non-standard locations
|
||||||
@@ -3218,8 +3283,11 @@ async function loadDashboard() {
|
|||||||
// Banner alerts (suspicious quantities + consumption predictions)
|
// Banner alerts (suspicious quantities + consumption predictions)
|
||||||
loadBannerAlerts();
|
loadBannerAlerts();
|
||||||
|
|
||||||
// Anti-waste section (load facts first so rotation has full dataset)
|
// Anti-waste section + Nutrition section: load in parallel
|
||||||
await _awLoadFacts();
|
const [, invForNutr] = await Promise.all([
|
||||||
|
_awLoadFacts(),
|
||||||
|
api('inventory_list').then(d => d.inventory || []).catch(() => []),
|
||||||
|
]);
|
||||||
_renderAntiWasteSection(
|
_renderAntiWasteSection(
|
||||||
statsData.used_30d || 0, statsData.wasted_30d || 0,
|
statsData.used_30d || 0, statsData.wasted_30d || 0,
|
||||||
statsData.used_prev_30d || 0, statsData.wasted_prev_30d || 0,
|
statsData.used_prev_30d || 0, statsData.wasted_prev_30d || 0,
|
||||||
@@ -3229,10 +3297,7 @@ async function loadDashboard() {
|
|||||||
_startAntiWasteAutoRefresh();
|
_startAntiWasteAutoRefresh();
|
||||||
|
|
||||||
// Nutrition section — built from the full inventory list
|
// Nutrition section — built from the full inventory list
|
||||||
try {
|
|
||||||
const invForNutr = (await api('inventory_list')).inventory || [];
|
|
||||||
_renderNutritionSection(invForNutr);
|
_renderNutritionSection(invForNutr);
|
||||||
} catch(_e) {}
|
|
||||||
_startInsightAlternation();
|
_startInsightAlternation();
|
||||||
|
|
||||||
// Opened (partially used products with known package capacity)
|
// Opened (partially used products with known package capacity)
|
||||||
@@ -3334,6 +3399,11 @@ async function loadDashboard() {
|
|||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Dashboard load error:', err);
|
console.error('Dashboard load error:', err);
|
||||||
|
// Remove shimmer even on error so numbers don't disappear forever
|
||||||
|
['stat-dispensa', 'stat-frigo', 'stat-freezer'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) { el.classList.remove('stat-loading'); if (el.textContent === '') el.textContent = '-'; }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3546,8 +3616,12 @@ async function loadBannerAlerts() {
|
|||||||
if (noExpiryDismissed[pid]) return; // user said "no expiry needed"
|
if (noExpiryDismissed[pid]) return; // user said "no expiry needed"
|
||||||
// Only flag perishable-looking categories or items with opened_at
|
// Only flag perishable-looking categories or items with opened_at
|
||||||
const cat = (item.category || '').toLowerCase();
|
const cat = (item.category || '').toLowerCase();
|
||||||
|
// Also infer category from name for items with missing/generic category
|
||||||
|
const guessedCat = guessCategoryFromName(item.name || '');
|
||||||
|
const perishableGuessed = ['latticini','carne','pesce','frutta','verdura','surgelati'].includes(guessedCat);
|
||||||
const likelyPerishable = item.opened_at ||
|
const likelyPerishable = item.opened_at ||
|
||||||
PERISHABLE_CATS.some(c => cat.includes(c));
|
PERISHABLE_CATS.some(c => cat.includes(c)) ||
|
||||||
|
perishableGuessed;
|
||||||
if (!likelyPerishable) return;
|
if (!likelyPerishable) return;
|
||||||
_bannerQueue.push({ type: 'no_expiry', data: item });
|
_bannerQueue.push({ type: 'no_expiry', data: item });
|
||||||
});
|
});
|
||||||
@@ -8498,6 +8572,7 @@ function toggleShoppingTag(itemIdx, tag) {
|
|||||||
|
|
||||||
// ===== SCAN FROM SHOPPING LIST =====
|
// ===== SCAN FROM SHOPPING LIST =====
|
||||||
function openScanForItem(idx) {
|
function openScanForItem(idx) {
|
||||||
|
loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list
|
||||||
const item = shoppingItems[idx];
|
const item = shoppingItems[idx];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
_spesaScanTarget = { name: item.name, rawName: item.rawName || '', idx };
|
_spesaScanTarget = { name: item.name, rawName: item.rawName || '', idx };
|
||||||
@@ -8660,6 +8735,284 @@ async function forceSyncBring() {
|
|||||||
showToast(`🔄 ${t('shopping.sync_done')}`, 'success');
|
showToast(`🔄 ${t('shopping.sync_done')}`, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// SHOPPING LIST PRICE ESTIMATION
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
let _pricesFetching = false;
|
||||||
|
/** In-memory price cache: survives list re-renders in the same session */
|
||||||
|
// Price cache persisted in sessionStorage — survives SPA navigation, cleared on tab close.
|
||||||
|
// Each entry includes _qty/_unit metadata so stale estimated_totals auto-invalidate when qty changes.
|
||||||
|
let _cachedPrices = (() => {
|
||||||
|
try { return JSON.parse(sessionStorage.getItem('_pricecache') || '{}'); } catch { return {}; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the items payload for the price API from the current shoppingItems array.
|
||||||
|
* Tries to parse quantity/unit from the Bring! specification field.
|
||||||
|
*/
|
||||||
|
function _buildPricePayload() {
|
||||||
|
return shoppingItems.map((item) => {
|
||||||
|
// Look up the matching smart shopping item to get reliable qty/unit data.
|
||||||
|
// Bring! spec strings can be stale or free-text — don't trust them for calculations.
|
||||||
|
const nameLower = item.name.toLowerCase();
|
||||||
|
const smart = (smartShoppingItems || []).find(s =>
|
||||||
|
s.name.toLowerCase() === nameLower ||
|
||||||
|
(s.shopping_name || '').toLowerCase() === nameLower
|
||||||
|
);
|
||||||
|
|
||||||
|
let quantity = smart?.suggested_qty || 1;
|
||||||
|
let unit = smart?.suggested_unit || smart?.unit || 'pz';
|
||||||
|
let default_quantity = smart?.default_qty || 0;
|
||||||
|
let package_unit = smart?.package_unit || '';
|
||||||
|
|
||||||
|
// If no smart match, fall back to parsing the Bring! spec (last resort)
|
||||||
|
if (!smart) {
|
||||||
|
const spec = item.specification || '';
|
||||||
|
const qtyMatch = spec.match(/(\d+(?:[.,]\d+)?)\s*(g|kg|ml|l|pz|conf|lt|liter|litre)\b/i);
|
||||||
|
if (qtyMatch) {
|
||||||
|
quantity = parseFloat(qtyMatch[1].replace(',', '.'));
|
||||||
|
unit = qtyMatch[2].toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: item.name, quantity, unit, default_quantity, package_unit };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTML for a price badge column.
|
||||||
|
* @param {Object} entry — API response (price_per_unit, unit_label, estimated_total, source_note)
|
||||||
|
* @param {string} sym — currency symbol like "€"
|
||||||
|
*/
|
||||||
|
function _buildPriceBadgeHTML(entry, sym) {
|
||||||
|
const mainLabel = entry.estimated_total != null
|
||||||
|
? `${sym}${entry.estimated_total.toFixed(2)}`
|
||||||
|
: `${sym}${entry.price_per_unit.toFixed(2)}`;
|
||||||
|
const unitLabel = entry.unit_label || '';
|
||||||
|
const unitLine = unitLabel && entry.price_per_unit != null
|
||||||
|
? `${sym}${entry.price_per_unit.toFixed(2)}/${unitLabel}`
|
||||||
|
: '';
|
||||||
|
const title = entry.source_note || '';
|
||||||
|
return `<div class="price-col-main" title="${escapeHtml(title)}">${mainLabel}</div>`
|
||||||
|
+ (unitLine ? `<div class="price-col-unit">${unitLine}</div>` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply price badges from in-memory cache (_cachedPrices) to the current DOM.
|
||||||
|
* Returns { total, count } of items successfully applied.
|
||||||
|
* Skips entries whose cached qty/unit no longer matches current suggested qty.
|
||||||
|
*/
|
||||||
|
function _applyPriceBadgesFromCache() {
|
||||||
|
const s = getSettings();
|
||||||
|
const sym = _currencySymbol(s.price_currency || 'EUR');
|
||||||
|
let total = 0, count = 0;
|
||||||
|
// Build a quick name→{quantity,unit} map from current smart data
|
||||||
|
const qtyMap = {};
|
||||||
|
for (const p of _buildPricePayload()) qtyMap[p.name] = p;
|
||||||
|
shoppingItems.forEach((item, idx) => {
|
||||||
|
const badge = document.getElementById(`price-badge-${idx}`);
|
||||||
|
if (!badge) return;
|
||||||
|
const entry = _cachedPrices[item.name];
|
||||||
|
if (!entry) return;
|
||||||
|
// Validate qty/unit — if smart data changed, treat as uncached
|
||||||
|
const current = qtyMap[item.name];
|
||||||
|
if (current && (entry._qty !== current.quantity || entry._unit !== current.unit)) return;
|
||||||
|
badge.innerHTML = _buildPriceBadgeHTML(entry, sym);
|
||||||
|
if (entry.estimated_total != null) { total += entry.estimated_total; count++; }
|
||||||
|
});
|
||||||
|
return { total, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply price badges to shopping items in the DOM (legacy batch variant).
|
||||||
|
* @param {Object} prices — name → price entry from API
|
||||||
|
* @param {string} currency — currency symbol fallback
|
||||||
|
*/
|
||||||
|
function _applyPriceBadges(prices, currency) {
|
||||||
|
const sym = _currencySymbol(currency);
|
||||||
|
shoppingItems.forEach((item, idx) => {
|
||||||
|
const badge = document.getElementById(`price-badge-${idx}`);
|
||||||
|
if (!badge) return;
|
||||||
|
const entry = prices[item.name];
|
||||||
|
if (!entry || entry.error) {
|
||||||
|
badge.innerHTML = `<span class="price-col-error">–</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
badge.innerHTML = _buildPriceBadgeHTML(entry, _currencySymbol(entry.currency || currency));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _currencySymbol(currency) {
|
||||||
|
const map = {
|
||||||
|
EUR: '€', USD: '$', GBP: '£', CHF: 'CHF ',
|
||||||
|
CAD: 'CA$', AUD: 'A$', BRL: 'R$', JPY: '¥',
|
||||||
|
SEK: 'kr', NOK: 'kr', DKK: 'kr', PLN: 'zł',
|
||||||
|
CZK: 'Kč', HUF: 'Ft', RON: 'lei',
|
||||||
|
};
|
||||||
|
return map[currency?.toUpperCase()] || currency || '€';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch prices for all shopping list items, one by one (real-time updates).
|
||||||
|
* Uses _cachedPrices for items already fetched this session (no API call needed).
|
||||||
|
* @param {boolean} forceRefresh — bypass all caches, re-fetch everything
|
||||||
|
*/
|
||||||
|
async function fetchAllPrices(forceRefresh = false) {
|
||||||
|
// Disable buttons immediately — even if we bail early, they stay disabled until
|
||||||
|
// the active fetch finishes and re-enables them in its finally block.
|
||||||
|
const fetchBtn = document.getElementById('btn-fetch-prices');
|
||||||
|
const refreshBtn = document.getElementById('btn-price-refresh');
|
||||||
|
if (fetchBtn) fetchBtn.disabled = true;
|
||||||
|
if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.textContent = '⏳'; }
|
||||||
|
|
||||||
|
if (_pricesFetching) return; // already running — buttons will be re-enabled by active fetch
|
||||||
|
if (!shoppingItems.length) {
|
||||||
|
if (fetchBtn) fetchBtn.disabled = false;
|
||||||
|
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = getSettings();
|
||||||
|
if (!s.price_enabled) {
|
||||||
|
if (fetchBtn) fetchBtn.disabled = false;
|
||||||
|
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pricesFetching = true;
|
||||||
|
|
||||||
|
const priceBar = document.getElementById('shopping-price-bar');
|
||||||
|
const loadingBar = document.getElementById('price-loading-bar');
|
||||||
|
const loadingInner = loadingBar ? loadingBar.querySelector('.price-loading-inner') : null;
|
||||||
|
const totalEl = document.getElementById('price-total-value');
|
||||||
|
|
||||||
|
if (priceBar) priceBar.style.display = 'block';
|
||||||
|
|
||||||
|
if (forceRefresh) {
|
||||||
|
// Full refresh: clear in-memory + sessionStorage cache, reset all badges to loading
|
||||||
|
_cachedPrices = {};
|
||||||
|
try { sessionStorage.removeItem('_pricecache'); } catch { /* ignore */ }
|
||||||
|
shoppingItems.forEach((_, idx) => {
|
||||||
|
const badge = document.getElementById(`price-badge-${idx}`);
|
||||||
|
if (badge) badge.innerHTML = `<span class="price-col-loading">…</span>`;
|
||||||
|
});
|
||||||
|
if (totalEl) totalEl.textContent = t('shopping.price_loading');
|
||||||
|
if (loadingBar) loadingBar.style.display = 'block';
|
||||||
|
if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '0%'; }
|
||||||
|
} else {
|
||||||
|
// Incremental: apply cached prices instantly, mark uncached as loading
|
||||||
|
const { total: cachedTotal, count: cachedCount } = _applyPriceBadgesFromCache();
|
||||||
|
shoppingItems.forEach((item, idx) => {
|
||||||
|
if (!_cachedPrices[item.name]) {
|
||||||
|
const badge = document.getElementById(`price-badge-${idx}`);
|
||||||
|
if (badge) badge.innerHTML = `<span class="price-col-loading">…</span>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const uncachedCount = shoppingItems.filter(i => !_cachedPrices[i.name]).length;
|
||||||
|
if (uncachedCount === 0) {
|
||||||
|
// All already cached — just show total and done
|
||||||
|
if (totalEl && cachedCount > 0) totalEl.textContent = `ca. ${_currencySymbol(s.price_currency || 'EUR')}${cachedTotal.toFixed(2)}`;
|
||||||
|
_pricesFetching = false;
|
||||||
|
if (fetchBtn) fetchBtn.disabled = false;
|
||||||
|
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (totalEl && cachedCount > 0) totalEl.textContent = `ca. ${_currencySymbol(s.price_currency || 'EUR')}${cachedTotal.toFixed(2)}`;
|
||||||
|
if (loadingBar) loadingBar.style.display = 'block';
|
||||||
|
if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '0%'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = s.language || 'it';
|
||||||
|
const country = s.price_country || 'Italia';
|
||||||
|
const currency = s.price_currency || 'EUR';
|
||||||
|
const sym = _currencySymbol(currency);
|
||||||
|
const items = _buildPricePayload();
|
||||||
|
const total = items.length;
|
||||||
|
// Running totals: only count items in the CURRENT shopping list with matching qty
|
||||||
|
let runningTotal = shoppingItems.reduce((sum, item) => {
|
||||||
|
const e = _cachedPrices[item.name];
|
||||||
|
const pi = items.find(x => x.name === item.name);
|
||||||
|
if (!e || !pi || e._qty !== pi.quantity || e._unit !== pi.unit) return sum;
|
||||||
|
return sum + (e?.estimated_total || 0);
|
||||||
|
}, 0);
|
||||||
|
let pricesFound = shoppingItems.filter(i => {
|
||||||
|
const e = _cachedPrices[i.name];
|
||||||
|
const pi = items.find(x => x.name === i.name);
|
||||||
|
return e && pi && e._qty === pi.quantity && e._unit === pi.unit && e.estimated_total != null;
|
||||||
|
}).length;
|
||||||
|
let processed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (!_pricesFetching) break; // guard: list was reloaded mid-fetch
|
||||||
|
|
||||||
|
const item = items[i];
|
||||||
|
const badge = document.getElementById(`price-badge-${i}`);
|
||||||
|
|
||||||
|
// Skip if already cached with same qty/unit (and not forceRefresh)
|
||||||
|
const cached = _cachedPrices[item.name];
|
||||||
|
if (!forceRefresh && cached && cached._qty === item.quantity && cached._unit === item.unit) {
|
||||||
|
processed++;
|
||||||
|
const progress = Math.round((processed / total) * 100);
|
||||||
|
if (loadingInner) { loadingInner.style.transition = 'width 0.3s ease'; loadingInner.style.width = `${progress}%`; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api('get_shopping_price', {}, 'POST', {
|
||||||
|
...item, country, currency, lang, force_refresh: forceRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data && data.success) {
|
||||||
|
_cachedPrices[item.name] = { ...data, _qty: item.quantity, _unit: item.unit };
|
||||||
|
if (badge) badge.innerHTML = _buildPriceBadgeHTML(data, sym);
|
||||||
|
if (data.estimated_total != null) {
|
||||||
|
runningTotal += data.estimated_total;
|
||||||
|
pricesFound++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (badge) badge.innerHTML = `<span class="price-col-error">–</span>`;
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
if (badge) badge.innerHTML = `<span class="price-col-error">–</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
const progress = Math.round((processed / total) * 100);
|
||||||
|
if (loadingInner) { loadingInner.style.transition = 'width 0.3s ease'; loadingInner.style.width = `${progress}%`; }
|
||||||
|
if (totalEl) {
|
||||||
|
totalEl.textContent = pricesFound > 0
|
||||||
|
? `ca. ${sym}${runningTotal.toFixed(2)}`
|
||||||
|
: t('shopping.price_loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_pricesFetching = false;
|
||||||
|
// Persist to sessionStorage so prices survive page navigation
|
||||||
|
try { sessionStorage.setItem('_pricecache', JSON.stringify(_cachedPrices)); } catch { /* quota */ }
|
||||||
|
if (loadingBar) loadingBar.style.display = 'none';
|
||||||
|
if (totalEl) totalEl.textContent = pricesFound > 0 ? `ca. ${sym}${runningTotal.toFixed(2)}` : '–';
|
||||||
|
if (fetchBtn) fetchBtn.disabled = false;
|
||||||
|
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
const btn = document.getElementById('btn-force-sync');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = `⏳ ${t('shopping.syncing')}`; }
|
||||||
|
// Clear auto-add/cleanup guards so the next run is unconditional.
|
||||||
|
// Do NOT clear _userPinnedBring — items the user manually added must stay protected.
|
||||||
|
localStorage.removeItem('_bringPurchasedBlocklist');
|
||||||
|
localStorage.removeItem('_autoAddedCriticalTs');
|
||||||
|
localStorage.removeItem('_bringCleanupTs');
|
||||||
|
localStorage.removeItem('_autoAddedBring');
|
||||||
|
logOperation('force_sync_bring', {});
|
||||||
|
// Reload everything from scratch
|
||||||
|
await loadShoppingList();
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = `🔄 ${t('shopping.force_sync')}`; }
|
||||||
|
showToast(`🔄 ${t('shopping.sync_done')}`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time cleanup: remove items from Bring! that were auto-added but the algorithm no
|
* One-time cleanup: remove items from Bring! that were auto-added but the algorithm no
|
||||||
* longer considers relevant. CONSERVATIVE: only removes items that match a known product
|
* longer considers relevant. CONSERVATIVE: only removes items that match a known product
|
||||||
@@ -8912,7 +9265,8 @@ async function loadSmartShopping() {
|
|||||||
);
|
);
|
||||||
smartShoppingItems = data.items;
|
smartShoppingItems = data.items;
|
||||||
_smartShoppingLastFetch = Date.now();
|
_smartShoppingLastFetch = Date.now();
|
||||||
// If the set of critical items changed, reset autoAdd/cleanup timers so
|
// NOTE: do NOT clear _cachedPrices here — qty validation (_qty/_unit metadata)
|
||||||
|
// handles stale entries automatically item by item.
|
||||||
// they run with fresh data on next shopping page load
|
// they run with fresh data on next shopping page load
|
||||||
const newCriticalNames = new Set(data.items.filter(i => i.urgency === 'critical').map(i => i.name));
|
const newCriticalNames = new Set(data.items.filter(i => i.urgency === 'critical').map(i => i.name));
|
||||||
const criticalChanged = [...prevCriticalNames].some(n => !newCriticalNames.has(n)) ||
|
const criticalChanged = [...prevCriticalNames].some(n => !newCriticalNames.has(n)) ||
|
||||||
@@ -9187,17 +9541,23 @@ async function addSmartToBring() {
|
|||||||
|
|
||||||
// Load just the shopping count for dashboard stat card
|
// Load just the shopping count for dashboard stat card
|
||||||
async function loadShoppingCount() {
|
async function loadShoppingCount() {
|
||||||
|
const el = document.getElementById('stat-spesa');
|
||||||
|
if (el) el.classList.add('stat-loading');
|
||||||
try {
|
try {
|
||||||
const data = await api('bring_list');
|
const data = await api('bring_list');
|
||||||
const el = document.getElementById('stat-spesa');
|
if (el) {
|
||||||
if (data.success && data.purchase) {
|
if (data.success && data.purchase) {
|
||||||
el.textContent = data.purchase.length;
|
el.textContent = data.purchase.length;
|
||||||
} else {
|
} else {
|
||||||
el.textContent = '-';
|
el.textContent = '-';
|
||||||
}
|
}
|
||||||
|
el.classList.remove('stat-loading');
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const el = document.getElementById('stat-spesa');
|
if (el) {
|
||||||
el.textContent = '-';
|
el.textContent = '-';
|
||||||
|
el.classList.remove('stat-loading');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Smart urgency badge: use cached data if fresh (< 2 min), else fetch
|
// Smart urgency badge: use cached data if fresh (< 2 min), else fetch
|
||||||
if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) {
|
if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) {
|
||||||
@@ -9322,6 +9682,36 @@ async function loadShoppingList() {
|
|||||||
const currentEl = document.getElementById('shopping-current');
|
const currentEl = document.getElementById('shopping-current');
|
||||||
const suggestionsEl = document.getElementById('shopping-suggestions');
|
const suggestionsEl = document.getElementById('shopping-suggestions');
|
||||||
|
|
||||||
|
// Track last user interaction timestamp to avoid disrupting active use
|
||||||
|
if (!loadShoppingList._lastUserInteraction) loadShoppingList._lastUserInteraction = 0;
|
||||||
|
|
||||||
|
// Background refresh: ALWAYS do a silent update — never show spinner or rebuild DOM
|
||||||
|
const isBackgroundCall = loadShoppingList._bgCall === true;
|
||||||
|
loadShoppingList._bgCall = false;
|
||||||
|
if (isBackgroundCall) {
|
||||||
|
try {
|
||||||
|
const data = await api('bring_list');
|
||||||
|
if (data.success) {
|
||||||
|
const newItems = data.purchase || [];
|
||||||
|
const newNames = new Set(newItems.map(i => i.name.toLowerCase()));
|
||||||
|
const prevNames = new Set((shoppingItems || []).map(i => i.name.toLowerCase()));
|
||||||
|
const hasChanges = newItems.length !== shoppingItems.length ||
|
||||||
|
[...newNames].some(n => !prevNames.has(n)) ||
|
||||||
|
[...prevNames].some(n => !newNames.has(n));
|
||||||
|
if (hasChanges) {
|
||||||
|
shoppingItems = newItems;
|
||||||
|
for (const name of Object.keys(_cachedPrices)) {
|
||||||
|
if (!newNames.has(name.toLowerCase())) delete _cachedPrices[name];
|
||||||
|
}
|
||||||
|
_syncTagsFromBringSpec();
|
||||||
|
renderShoppingItems();
|
||||||
|
}
|
||||||
|
loadShoppingCount();
|
||||||
|
}
|
||||||
|
} catch(_e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
statusEl.style.display = 'block';
|
statusEl.style.display = 'block';
|
||||||
statusEl.innerHTML = `<div class="bring-loading"><div class="loading-spinner"></div> ${t('shopping.bring_loading')}</div>`;
|
statusEl.innerHTML = `<div class="bring-loading"><div class="loading-spinner"></div> ${t('shopping.bring_loading')}</div>`;
|
||||||
currentEl.style.display = 'none';
|
currentEl.style.display = 'none';
|
||||||
@@ -9374,6 +9764,10 @@ async function loadShoppingList() {
|
|||||||
if (removedNames.length) _markBringPurchased(removedNames);
|
if (removedNames.length) _markBringPurchased(removedNames);
|
||||||
}
|
}
|
||||||
shoppingItems = newItems;
|
shoppingItems = newItems;
|
||||||
|
// Evict removed items from price cache so stale prices don't reappear
|
||||||
|
for (const name of Object.keys(_cachedPrices)) {
|
||||||
|
if (!newNames.has(name.toLowerCase())) delete _cachedPrices[name];
|
||||||
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -9515,6 +9909,8 @@ async function renderShoppingItems() {
|
|||||||
).join('')}
|
).join('')}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
const priceEnabled = getSettings().price_enabled;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="shopping-item" 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>
|
||||||
@@ -9528,6 +9924,7 @@ async function renderShoppingItems() {
|
|||||||
${_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>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${priceEnabled ? `<div class="shopping-item-price-col" id="price-badge-${idx}"><span class="price-col-loading">…</span></div>` : ''}
|
||||||
<div class="shopping-item-right" onclick="event.stopPropagation()">
|
<div class="shopping-item-right" onclick="event.stopPropagation()">
|
||||||
<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>
|
||||||
@@ -9540,9 +9937,26 @@ async function renderShoppingItems() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Trigger async price loading if enabled
|
||||||
|
const s2 = getSettings();
|
||||||
|
if (s2.price_enabled && shoppingItems.length > 0) {
|
||||||
|
document.getElementById('shopping-price-bar').style.display = 'block';
|
||||||
|
document.getElementById('btn-fetch-prices').style.display = 'inline-flex';
|
||||||
|
// Allow a new fetch (re-render may have happened while old fetch was running)
|
||||||
|
_pricesFetching = false;
|
||||||
|
// Immediately apply any prices already fetched this session — no flicker, no loading bar
|
||||||
|
_applyPriceBadgesFromCache();
|
||||||
|
// Fetch only items not yet priced (or all if none are cached yet)
|
||||||
|
fetchAllPrices(false);
|
||||||
|
} else {
|
||||||
|
document.getElementById('shopping-price-bar').style.display = 'none';
|
||||||
|
document.getElementById('btn-fetch-prices').style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleShoppingTagMenu(btn) {
|
function toggleShoppingTagMenu(btn) {
|
||||||
|
loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list
|
||||||
const container = btn.closest('.shopping-item-body').querySelector('.shopping-tag-menu-container');
|
const container = btn.closest('.shopping-item-body').querySelector('.shopping-tag-menu-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const isOpen = container.style.display !== 'none';
|
const isOpen = container.style.display !== 'none';
|
||||||
@@ -9552,6 +9966,7 @@ function toggleShoppingTagMenu(btn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeBringItem(idx) {
|
async function removeBringItem(idx) {
|
||||||
|
loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list
|
||||||
const item = shoppingItems[idx];
|
const item = shoppingItems[idx];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
try {
|
try {
|
||||||
@@ -10074,6 +10489,51 @@ async function _doUndoTransaction(id, type, name) {
|
|||||||
|
|
||||||
// ===== WEEKLY MEAL PLAN =====
|
// ===== WEEKLY MEAL PLAN =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
/**
|
||||||
|
* Keywords to check in inventory names for each meal plan type.
|
||||||
|
* Mirror of PHP $typeKeywords in api/index.php.
|
||||||
|
*/
|
||||||
|
const MEAL_PLAN_TYPE_KEYWORDS = {
|
||||||
|
pesce: ['tonno','salmone','merluzzo','branzino','orata','sardine','acciughe','alici','gamberi','cozze','vongole','polpo','calamari','seppia','sgombro','trota','baccalà','dentice','spigola','pesce'],
|
||||||
|
carne: ['pollo','manzo','maiale','vitello','agnello','tacchino','salsiccia','hamburger','bistecca','cotoletta','pancetta','speck','carne','arrosto','filetto','lonza','braciola'],
|
||||||
|
pasta: ['pasta','spaghetti','penne','rigatoni','fusilli','tagliatelle','lasagne','farfalle','orecchiette','bucatini','linguine','maccheroni','gnocchi','pennette','bavette'],
|
||||||
|
riso: ['riso','basmati','arborio','carnaroli','parboiled'],
|
||||||
|
legumi: ['fagioli','ceci','lenticchie','piselli','fave','lupini','soia','legumi','borlotti','cannellini','azuki'],
|
||||||
|
uova: ['uova','uovo'],
|
||||||
|
formaggio: ['formaggio','parmigiano','mozzarella','ricotta','pecorino','grana','gorgonzola','scamorza','fontina','emmental','asiago','provola','provolone','taleggio','stracchino'],
|
||||||
|
pizza: ['farina','lievito','pizza','focaccia'],
|
||||||
|
affettati: ['prosciutto','salame','bresaola','mortadella','speck','coppa','affettati','wurstel','piadina'],
|
||||||
|
verdure: ['zucchine','zucchina','melanzane','peperoni','spinaci','cavolfiore','broccoli','carote','zucca','bietole','cavolo','carciofi','asparagi','lattuga','rucola','radicchio','finocchio','cipolla','porri','verdure'],
|
||||||
|
zuppa: ['brodo','zuppa','minestra','minestrone','orzo','farro','fagioli','ceci','lenticchie'],
|
||||||
|
insalata: ['insalata','lattuga','rucola','spinaci','radicchio','misticanza','valeriana','songino'],
|
||||||
|
pane: ['pane','pancarrè','baguette','toast','tramezzino','crackers','grissini','ciabatta'],
|
||||||
|
dolce: ['cioccolato','cacao','zucchero','miele','marmellata','nutella','savoiardi','biscotti','panna'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if today's meal plan type has at least one ingredient in the inventory.
|
||||||
|
* Returns true if available (or type is unknown/libero), false if definitely missing.
|
||||||
|
*/
|
||||||
|
async function _checkMealPlanIngredientAvailable(typeId) {
|
||||||
|
if (!typeId || typeId === 'libero') return true;
|
||||||
|
const keywords = MEAL_PLAN_TYPE_KEYWORDS[typeId];
|
||||||
|
if (!keywords || keywords.length === 0) return true;
|
||||||
|
try {
|
||||||
|
const data = await api('inventory_list');
|
||||||
|
const items = (data.inventory || []).filter(i => parseFloat(i.quantity) > 0);
|
||||||
|
for (const item of items) {
|
||||||
|
const nameLower = (item.name + ' ' + (item.brand || '')).toLowerCase();
|
||||||
|
for (const kw of keywords) {
|
||||||
|
if (nameLower.includes(kw)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return true; // on error, assume available to avoid blocking UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All selectable meal categories per slot.
|
* All selectable meal categories per slot.
|
||||||
* id must be URL-safe; icon + label shown in UI.
|
* id must be URL-safe; icon + label shown in UI.
|
||||||
@@ -10157,6 +10617,21 @@ function onMealPlanEnabledChange(el) {
|
|||||||
if (picker) picker.style.display = 'none';
|
if (picker) picker.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPriceCountryChange() {
|
||||||
|
// Auto-suggest currency based on country
|
||||||
|
const countryEl = document.getElementById('setting-price-country');
|
||||||
|
const currencyEl = document.getElementById('setting-price-currency');
|
||||||
|
if (!countryEl || !currencyEl) return;
|
||||||
|
const map = {
|
||||||
|
'USA': 'USD', 'UK': 'GBP', 'Switzerland': 'CHF', 'Canada': 'CAD',
|
||||||
|
'Australia': 'AUD', 'Brazil': 'BRL', 'Japan': 'JPY', 'Sweden': 'SEK',
|
||||||
|
'Norway': 'NOK', 'Denmark': 'DKK', 'Poland': 'PLN',
|
||||||
|
};
|
||||||
|
const suggested = map[countryEl.value];
|
||||||
|
if (suggested) currencyEl.value = suggested;
|
||||||
|
// Default to EUR for EU countries
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the weekly meal plan editor into #meal-plan-grid.
|
* Render the weekly meal plan editor into #meal-plan-grid.
|
||||||
* Each cell shows the current type badge + a picker dropdown.
|
* Each cell shows the current type badge + a picker dropdown.
|
||||||
@@ -11572,6 +12047,17 @@ function _renderMealPlanHint(mealSlot) {
|
|||||||
if (chipLabel) chipLabel.textContent = `${mpt.icon} ${mpt.label}`;
|
if (chipLabel) chipLabel.textContent = `${mpt.icon} ${mpt.label}`;
|
||||||
if (chipCb) chipCb.checked = true;
|
if (chipCb) chipCb.checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Async: check if the required ingredient is actually in inventory.
|
||||||
|
// If not, disable the chip and warn the user.
|
||||||
|
_checkMealPlanIngredientAvailable(typeId).then(available => {
|
||||||
|
if (!available && chipWrap && chipWrap.style.display !== 'none') {
|
||||||
|
if (chipCb) { chipCb.checked = false; chipCb.disabled = true; }
|
||||||
|
if (chipLabel) chipLabel.textContent = `${mpt.icon} ${mpt.label} ⚠️ ${t('meal_plan.not_available') || 'non disponibile'}`;
|
||||||
|
chipWrap.style.opacity = '0.5';
|
||||||
|
if (banner) banner.style.display = 'none';
|
||||||
|
}
|
||||||
|
}).catch(() => {/* ignore */});
|
||||||
}
|
}
|
||||||
|
|
||||||
function regenerateRecipe() {
|
function regenerateRecipe() {
|
||||||
@@ -12841,6 +13327,8 @@ async function _initApp() {
|
|||||||
if (missing.length > 0 && !_demoMode) {
|
if (missing.length > 0 && !_demoMode) {
|
||||||
showSetupWizard(missing);
|
showSetupWizard(missing);
|
||||||
}
|
}
|
||||||
|
// Reuse the already-fetched serverSettings to avoid a second get_settings request
|
||||||
|
_applySyncedSettings(serverSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate old session-based flags to time-based
|
// Migrate old session-based flags to time-based
|
||||||
@@ -12852,6 +13340,7 @@ async function _initApp() {
|
|||||||
localStorage.removeItem('_bgBringSyncTs');
|
localStorage.removeItem('_bgBringSyncTs');
|
||||||
localStorage.setItem('_bgBringSyncReset_v1', '1');
|
localStorage.setItem('_bgBringSyncReset_v1', '1');
|
||||||
}
|
}
|
||||||
|
// syncSettingsFromDB only needs to fetch app_settings_get for review flags now
|
||||||
syncSettingsFromDB().then(() => {
|
syncSettingsFromDB().then(() => {
|
||||||
scaleInit(); // connect to smart scale gateway if configured (needs settings)
|
scaleInit(); // connect to smart scale gateway if configured (needs settings)
|
||||||
initInactivityWatcher();
|
initInactivityWatcher();
|
||||||
@@ -12883,6 +13372,7 @@ async function _initApp() {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (_screensaverActive) return;
|
if (_screensaverActive) return;
|
||||||
if (_currentPageId === 'shopping') {
|
if (_currentPageId === 'shopping') {
|
||||||
|
loadShoppingList._bgCall = true;
|
||||||
loadShoppingList();
|
loadShoppingList();
|
||||||
} else {
|
} else {
|
||||||
loadShoppingCount();
|
loadShoppingCount();
|
||||||
@@ -12892,6 +13382,8 @@ async function _initApp() {
|
|||||||
// 3) Aggiorna immediatamente quando la tab torna visibile (es. torni da Bring! app)
|
// 3) Aggiorna immediatamente quando la tab torna visibile (es. torni da Bring! app)
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
|
// Always treat visibility restore as a background call for shopping
|
||||||
|
if (_currentPageId === 'shopping') loadShoppingList._bgCall = true;
|
||||||
refreshCurrentPage();
|
refreshCurrentPage();
|
||||||
_checkWebappUpdate(); // also check for app updates when user returns to tab
|
_checkWebappUpdate(); // also check for app updates when user returns to tab
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,645 +0,0 @@
|
|||||||
{
|
|
||||||
"de2it": {
|
|
||||||
"Getreideriegel": "Barretta ai cereali",
|
|
||||||
"Glasreiniger": "Pulizia vetri",
|
|
||||||
"Gartenwerkzeug": "Atrezzi da giardino",
|
|
||||||
"Getränke": "Bibite",
|
|
||||||
"Hackfleisch": "Carne macinata",
|
|
||||||
"Baumarkt & Garten": "Fai da te & Giardino",
|
|
||||||
"Kekse": "Biscotti",
|
|
||||||
"Salami": "Salame",
|
|
||||||
"Lippenpomade": "Burrocacao",
|
|
||||||
"Putzmittel": "Detergente",
|
|
||||||
"Samen": "Sementi",
|
|
||||||
"Wassermelone": "Anguria",
|
|
||||||
"Schokolade": "Cioccolato",
|
|
||||||
"Fertig- & Tiefkühlprodukte": "Piatti Pronti & Surgelati",
|
|
||||||
"Käse": "Formaggio",
|
|
||||||
"Giesskanne": "Annaffiatoio",
|
|
||||||
"Bratwurst": "Wurstel",
|
|
||||||
"Fenchel": "Finocchio",
|
|
||||||
"Fruchtsaft": "Succo di frutta",
|
|
||||||
"Grissini": "Grissini",
|
|
||||||
"Brokkoli": "Broccoli",
|
|
||||||
"Eistee": "Tè freddo",
|
|
||||||
"Haarspray": "Spray",
|
|
||||||
"Pflaumen": "Susina",
|
|
||||||
"Pommes Chips": "Patatine",
|
|
||||||
"Schweinefleisch": "Carne di maiale",
|
|
||||||
"Backpapier": "Carta da forno",
|
|
||||||
"Brot": "Pane",
|
|
||||||
"Orangensaft": "Succo d'arancia",
|
|
||||||
"Geschirrsalz": "Sale Lavastoviglie",
|
|
||||||
"Gipfeli": "Cornetti",
|
|
||||||
"Birnen": "Pere",
|
|
||||||
"Eier": "Uova",
|
|
||||||
"Makeup Entferner": "Struccante",
|
|
||||||
"Steinpilze": "Porcini",
|
|
||||||
"Kartoffeln": "Patate",
|
|
||||||
"Rasierklingen": "Ricambi rasoio",
|
|
||||||
"Gemüse": "Verdure",
|
|
||||||
"Kaffee": "Caffè",
|
|
||||||
"Frischkäse": "Formaggio cremoso",
|
|
||||||
"Zutaten & Gewürze": "Ingredienti & Spezie",
|
|
||||||
"Öl": "Olio",
|
|
||||||
"Trauben": "Uva",
|
|
||||||
"Salz": "Sale",
|
|
||||||
"Balsamico": "Aceto Balsamico",
|
|
||||||
"Fisch": "Pesce",
|
|
||||||
"Radicchio": "Radicchio",
|
|
||||||
"Geschenk": "Regalo",
|
|
||||||
"Blumen": "Fiori",
|
|
||||||
"Limonade": "Bibite",
|
|
||||||
"Schwamm": "Spugna",
|
|
||||||
"Limette": "Limone verde",
|
|
||||||
"Aubergine": "Melanzana",
|
|
||||||
"Schinken": "Prosciutto cotto",
|
|
||||||
"Zucchetti": "Zucchine",
|
|
||||||
"Rum": "Rum",
|
|
||||||
"Frühlingszwiebeln": "Cipollotti",
|
|
||||||
"Spargel": "Asparagi",
|
|
||||||
"Sonnencreme": "Crema da sole",
|
|
||||||
"Gnocchi": "Gnocchi",
|
|
||||||
"Handcreme": "Crema mani",
|
|
||||||
"Schnittlauch": "Erba Cipollina",
|
|
||||||
"Snacks & Süsswaren": "Snack & Dolci",
|
|
||||||
"Pelati": "Pelati",
|
|
||||||
"Fischstäbli": "Bastoncini di pesce",
|
|
||||||
"Margarine": "Margarina",
|
|
||||||
"Fleisch & Fisch": "Carne & Pesce",
|
|
||||||
"Zigaretten": "Sigarette",
|
|
||||||
"Oregano": "Origano",
|
|
||||||
"Basmatireis": "Riso Basmati",
|
|
||||||
"Zahnseide": "Filo interdentale",
|
|
||||||
"Tofu": "Tofu",
|
|
||||||
"Energy Drink": "Energy Drink",
|
|
||||||
"Peperoni": "Peperoni",
|
|
||||||
"Sirup": "Sciroppo",
|
|
||||||
"Feigen": "Fichi",
|
|
||||||
"Haselnüsse": "Nocciole",
|
|
||||||
"Mehl": "Farina",
|
|
||||||
"Haferflocken": "Avena",
|
|
||||||
"Apfelmus": "Composta di mele",
|
|
||||||
"Reis": "Riso",
|
|
||||||
"Mascarpone": "Mascarpone",
|
|
||||||
"Rasenmäher": "Tosaerba",
|
|
||||||
"Schnitzel": "Scaloppine",
|
|
||||||
"Grill": "Griglia",
|
|
||||||
"Ketchup": "Ketchup",
|
|
||||||
"Lachs": "Salmone",
|
|
||||||
"Zwiebeln": "Cipolle",
|
|
||||||
"Beeren": "Bacche",
|
|
||||||
"Pflaster": "Cerotti",
|
|
||||||
"Fischfutter": "Mangime per pesci",
|
|
||||||
"Kerzen": "Candele",
|
|
||||||
"Früchte": "Frutta",
|
|
||||||
"Kalbfleisch": "Carne di vitello",
|
|
||||||
"Rasierschaum": "Schiuma da barba",
|
|
||||||
"Ingwer": "Zenzero",
|
|
||||||
"Pfirsich": "Pesca",
|
|
||||||
"Sauerrahm": "Panna Acidula",
|
|
||||||
"Früchte & Gemüse": "Frutta & Verdura",
|
|
||||||
"Lasagne": "Lasagne",
|
|
||||||
"Pinsel": "Pennello",
|
|
||||||
"Hefe": "Lievito",
|
|
||||||
"Kuchen": "Torta",
|
|
||||||
"Prosecco": "Prosecco",
|
|
||||||
"Tampons": "Assorbenti",
|
|
||||||
"Thunfisch": "Pesce Tonno",
|
|
||||||
"Zucker": "Zucchero",
|
|
||||||
"Piadina": "Piadina",
|
|
||||||
"Pouletbrüstli": "Petto di pollo",
|
|
||||||
"Kastanien": "Castagne",
|
|
||||||
"Blumenkohl": "Cavolfiore",
|
|
||||||
"Salat": "Insalata",
|
|
||||||
"Fleisch": "Carne",
|
|
||||||
"Corn Flakes": "Cereali colazione",
|
|
||||||
"Merendina": "Merendina",
|
|
||||||
"Knoblauch": "Aglio",
|
|
||||||
"Karotten": "Carote",
|
|
||||||
"Toast": "Toast",
|
|
||||||
"Waschmittel": "Detersivo lavatrice",
|
|
||||||
"Salatsauce": "Condimento insalata",
|
|
||||||
"Hundefutter": "Cibo per cani",
|
|
||||||
"Vanillezucker": "Zucchero vanigliato",
|
|
||||||
"Mundspülung": "Collutorio",
|
|
||||||
"Babynahrung": "Alimenti Bimbi",
|
|
||||||
"Windeln": "Pannolini",
|
|
||||||
"Kondome": "Preservativo",
|
|
||||||
"Couscous": "Couscous",
|
|
||||||
"Geschirrglanz": "Brillantante",
|
|
||||||
"Aprikosen": "Albicocche",
|
|
||||||
"Himbeeren": "Lamponi",
|
|
||||||
"Oliven": "Olive",
|
|
||||||
"Lebkuchen": "Panpepato",
|
|
||||||
"Getreideprodukte": "Pasta, Riso & Cereali",
|
|
||||||
"Kürbis": "Zucca",
|
|
||||||
"Tonic Water": "Tonic",
|
|
||||||
"Nektarine": "Pesche noci",
|
|
||||||
"Penne": "Penne",
|
|
||||||
"Shampoo": "Shampoo",
|
|
||||||
"Whisky": "Whisky",
|
|
||||||
"Datteln": "Datteri",
|
|
||||||
"Kakao": "Cacao",
|
|
||||||
"Olivenöl": "Olio d'oliva",
|
|
||||||
"Bohnen": "Fagioli",
|
|
||||||
"Pizza": "Pizza",
|
|
||||||
"Kiwi": "Kiwi",
|
|
||||||
"Poulet": "Pollo",
|
|
||||||
"Wasser": "Acqua",
|
|
||||||
"Pasta": "Pasta",
|
|
||||||
"Milch": "Latte",
|
|
||||||
"Kirschen": "Ciliegie",
|
|
||||||
"Mandeln": "Mandorle",
|
|
||||||
"Milch & Käse": "Latte & Formaggi",
|
|
||||||
"Kichererbsen": "Ceci",
|
|
||||||
"Kosmetiktücher": "Kleenex",
|
|
||||||
"Kaugummi": "Gomma da masticare",
|
|
||||||
"Gesichtscreme": "Crema viso",
|
|
||||||
"getrocknete Tomaten": "Pomodori secchi",
|
|
||||||
"Champignons": "Champignons",
|
|
||||||
"Cola Light": "Cola Light",
|
|
||||||
"Orange": "Arance",
|
|
||||||
"Alufolie": "Foglio di alluminio",
|
|
||||||
"Melone": "Melone",
|
|
||||||
"Bananen": "Banane",
|
|
||||||
"Zahnbürsten": "Spazzolino",
|
|
||||||
"Zimt": "Cannella",
|
|
||||||
"Äpfel": "Mele",
|
|
||||||
"Cola": "Cola",
|
|
||||||
"Bouillon": "Brodo",
|
|
||||||
"Salbei": "Salvia",
|
|
||||||
"Soyasauce": "Salsa di soia",
|
|
||||||
"Rohschinken": "Prosciutto crudo",
|
|
||||||
"Reibkäse": "Formaggio grattugiato",
|
|
||||||
"Aufschnitt": "Affettato",
|
|
||||||
"Geschirrtabs": "Past. Lavastoviglie",
|
|
||||||
"Sonnenschirm": "Ombrellone",
|
|
||||||
"Bresaola": "Bresaola",
|
|
||||||
"Mineralwasser": "Acqua minerale",
|
|
||||||
"Taschentücher": "Fazzoletti",
|
|
||||||
"Haushalt & Gesundheit": "Casa & Igiene",
|
|
||||||
"Feuchttücher": "Salviette",
|
|
||||||
"Erbsen": "Piselli",
|
|
||||||
"Parmesan": "Parmigiano",
|
|
||||||
"Nougatcreme": "Crema gianduia",
|
|
||||||
"Speck": "Pancetta",
|
|
||||||
"Tierbedarf": "Animali",
|
|
||||||
"Avocado": "Avocado",
|
|
||||||
"Paprikapulver": "Paprica",
|
|
||||||
"Abfallsäcke": "Sacchi della spazzatura",
|
|
||||||
"Essig": "Aceto",
|
|
||||||
"Dünger": "Concime",
|
|
||||||
"Pilze": "Funghi",
|
|
||||||
"Batterien": "Batterie",
|
|
||||||
"Tomatensauce": "Sugo di pomodoro",
|
|
||||||
"Rucola": "Rucola",
|
|
||||||
"Bier": "Birra",
|
|
||||||
"Blumenerde": "Terriccio",
|
|
||||||
"Rhabarber": "Rabarbaro",
|
|
||||||
"Artischocken": "Carciofi",
|
|
||||||
"Rosmarin": "Rosmarino",
|
|
||||||
"Thon": "Tonno",
|
|
||||||
"Linsenmittel": "Soluzione lenti",
|
|
||||||
"Nagellackentferner": "Acetone",
|
|
||||||
"Bodylotion": "Crema corpo",
|
|
||||||
"Apfelsaft": "Succo di mela",
|
|
||||||
"Guetzli": "Biscotti di Natale",
|
|
||||||
"Tomatenmark": "Concentrato Pomodoro",
|
|
||||||
"Gurke": "Cetriolo",
|
|
||||||
"Holzkohle": "Carbonella",
|
|
||||||
"Basilikum": "Basilico",
|
|
||||||
"Joghurt": "Yogurt",
|
|
||||||
"Getränke & Tabak": "Bevande & Tabacco",
|
|
||||||
"Pop Corn": "Popcorn",
|
|
||||||
"Weichspüler": "Ammorbidente",
|
|
||||||
"Butter": "Burro",
|
|
||||||
"Rotwein": "Vino Rosso",
|
|
||||||
"Frankfurter": "Luganega",
|
|
||||||
"Schnaps": "Grappa",
|
|
||||||
"Tomaten": "Pomodori",
|
|
||||||
"Ricotta": "Ricotta",
|
|
||||||
"Watterondellen": "Dischetti di cotone",
|
|
||||||
"Erdbeeren": "Fragole",
|
|
||||||
"Vogelfutter": "Cibo per uccelli",
|
|
||||||
"Thymian": "Timo",
|
|
||||||
"Puderzucker": "Zucchero a velo",
|
|
||||||
"Kräuterbutter": "Burro alle erbe",
|
|
||||||
"Kaki": "Cachi",
|
|
||||||
"Erdnüsse": "Arachidi",
|
|
||||||
"Pfefferkörner": "Grani di pepe",
|
|
||||||
"Schrauben": "Viti",
|
|
||||||
"Sardellen": "Acciughe",
|
|
||||||
"Rindfleisch": "Carne di manzo",
|
|
||||||
"Conditioner": "Balsamo",
|
|
||||||
"Pizzateig": "Pasta per pizza",
|
|
||||||
"Zitrone": "Limone",
|
|
||||||
"Nägel": "Chiodi",
|
|
||||||
"Peperoncini": "Peperoncini",
|
|
||||||
"Senf": "Senape",
|
|
||||||
"Brötchen": "Panini",
|
|
||||||
"Baumnüsse": "Noci",
|
|
||||||
"Nudeln": "Tagliatelle",
|
|
||||||
"Wurst": "Salsiccia",
|
|
||||||
"Pudding": "Pudding",
|
|
||||||
"Griess": "Semolino",
|
|
||||||
"Mandarinen": "Mandarini",
|
|
||||||
"Weisswein": "Vino bianco",
|
|
||||||
"Blätterteig": "Pasta Sfoglia",
|
|
||||||
"Cherrytomaten": "Pomodorini",
|
|
||||||
"Pfefferminze": "Menta",
|
|
||||||
"Katzenstreu": "Sabbia gatti",
|
|
||||||
"Zwetschgen": "Prugne",
|
|
||||||
"Brombeeren": "More",
|
|
||||||
"Gin": "Gin",
|
|
||||||
"Vodka": "Vodka",
|
|
||||||
"Honig": "Miele",
|
|
||||||
"WC-Papier": "Carta igienica",
|
|
||||||
"Brot & Gebäck": "Panetteria",
|
|
||||||
"Paniermehl": "Pangrattato",
|
|
||||||
"Abwaschmittel": "Detersivo Piatti",
|
|
||||||
"Rahm": "Panna",
|
|
||||||
"Mayonnaise": "Maionese",
|
|
||||||
"Spülmittel": "Detersivo",
|
|
||||||
"Sellerie": "Sedano",
|
|
||||||
"Lauch": "Porro",
|
|
||||||
"Rindsgeschnetzeltes": "Sminuzzato manzo",
|
|
||||||
"WC-Reiniger": "Detergente per WC",
|
|
||||||
"Baguette": "Baguette",
|
|
||||||
"Konfitüre": "Marmellata",
|
|
||||||
"Schmerzmittel": "Analgesico",
|
|
||||||
"Badreiniger": "Pulizia bagno",
|
|
||||||
"Mango": "Mango",
|
|
||||||
"Mozzarella": "Mozzarella",
|
|
||||||
"Ananas": "Ananas",
|
|
||||||
"Propangas": "Propano",
|
|
||||||
"Bratensauce": "Salsa per arrosto",
|
|
||||||
"Orecchiette": "Orecchiette",
|
|
||||||
"Lamm": "Agnello",
|
|
||||||
"Frischhaltefolie": "Pellicole",
|
|
||||||
"Zahnpasta": "Dentifricio",
|
|
||||||
"Spaghetti": "Spaghetti",
|
|
||||||
"Haargel": "Gel Styling",
|
|
||||||
"Snacks": "Snack",
|
|
||||||
"Petersilie": "Prezzemolo",
|
|
||||||
"Grapefruit": "Pompelmo",
|
|
||||||
"Grana Padano": "Grana Padano",
|
|
||||||
"Servietten": "Tovaglioli",
|
|
||||||
"Töpfe": "Vasi",
|
|
||||||
"Linsen": "Lenticchie",
|
|
||||||
"Duschmittel": "Crema doccia",
|
|
||||||
"Gorgonzola": "Gorgonzola",
|
|
||||||
"Spinat": "Spinaci",
|
|
||||||
"Backpulver": "Bicarbonato",
|
|
||||||
"Risottoreis": "Risotto",
|
|
||||||
"Rasierer": "Rasoio",
|
|
||||||
"Pommes Frites": "Patate fritte",
|
|
||||||
"Deo": "Deodorante",
|
|
||||||
"Pflanzen": "Piante",
|
|
||||||
"Katzenfutter": "Cibo per gatti",
|
|
||||||
"Geschenkpapier": "Carta da regalo",
|
|
||||||
"Tee": "Tè",
|
|
||||||
"Wattestäbchen": "Bastoncini cotonati",
|
|
||||||
"Kräuter": "Erbe",
|
|
||||||
"Seife": "Sapone",
|
|
||||||
"Glacé": "Gelato",
|
|
||||||
"Mais": "Mais",
|
|
||||||
"Haushaltspapier": "Carta domestica",
|
|
||||||
"Polenta": "Polenta",
|
|
||||||
"Eigene Artikel": "Tuoi articoli",
|
|
||||||
"Zuletzt verwendet": "Utilizzato per ultimo"
|
|
||||||
},
|
|
||||||
"it2de": {
|
|
||||||
"barretta ai cereali": "Getreideriegel",
|
|
||||||
"pulizia vetri": "Glasreiniger",
|
|
||||||
"atrezzi da giardino": "Gartenwerkzeug",
|
|
||||||
"bibite": "Limonade",
|
|
||||||
"carne macinata": "Hackfleisch",
|
|
||||||
"fai da te & giardino": "Baumarkt & Garten",
|
|
||||||
"biscotti": "Kekse",
|
|
||||||
"salame": "Salami",
|
|
||||||
"burrocacao": "Lippenpomade",
|
|
||||||
"detergente": "Putzmittel",
|
|
||||||
"sementi": "Samen",
|
|
||||||
"anguria": "Wassermelone",
|
|
||||||
"cioccolato": "Schokolade",
|
|
||||||
"piatti pronti & surgelati": "Fertig- & Tiefkühlprodukte",
|
|
||||||
"formaggio": "Käse",
|
|
||||||
"annaffiatoio": "Giesskanne",
|
|
||||||
"wurstel": "Bratwurst",
|
|
||||||
"finocchio": "Fenchel",
|
|
||||||
"succo di frutta": "Fruchtsaft",
|
|
||||||
"grissini": "Grissini",
|
|
||||||
"broccoli": "Brokkoli",
|
|
||||||
"tè freddo": "Eistee",
|
|
||||||
"spray": "Haarspray",
|
|
||||||
"susina": "Pflaumen",
|
|
||||||
"patatine": "Pommes Chips",
|
|
||||||
"carne di maiale": "Schweinefleisch",
|
|
||||||
"carta da forno": "Backpapier",
|
|
||||||
"pane": "Brot",
|
|
||||||
"succo d'arancia": "Orangensaft",
|
|
||||||
"sale lavastoviglie": "Geschirrsalz",
|
|
||||||
"cornetti": "Gipfeli",
|
|
||||||
"pere": "Birnen",
|
|
||||||
"uova": "Eier",
|
|
||||||
"struccante": "Makeup Entferner",
|
|
||||||
"porcini": "Steinpilze",
|
|
||||||
"patate": "Kartoffeln",
|
|
||||||
"ricambi rasoio": "Rasierklingen",
|
|
||||||
"verdure": "Gemüse",
|
|
||||||
"caffè": "Kaffee",
|
|
||||||
"formaggio cremoso": "Frischkäse",
|
|
||||||
"ingredienti & spezie": "Zutaten & Gewürze",
|
|
||||||
"olio": "Öl",
|
|
||||||
"uva": "Trauben",
|
|
||||||
"sale": "Salz",
|
|
||||||
"aceto balsamico": "Balsamico",
|
|
||||||
"pesce": "Fisch",
|
|
||||||
"radicchio": "Radicchio",
|
|
||||||
"regalo": "Geschenk",
|
|
||||||
"fiori": "Blumen",
|
|
||||||
"spugna": "Schwamm",
|
|
||||||
"limone verde": "Limette",
|
|
||||||
"melanzana": "Aubergine",
|
|
||||||
"prosciutto cotto": "Schinken",
|
|
||||||
"zucchine": "Zucchetti",
|
|
||||||
"rum": "Rum",
|
|
||||||
"cipollotti": "Frühlingszwiebeln",
|
|
||||||
"asparagi": "Spargel",
|
|
||||||
"crema da sole": "Sonnencreme",
|
|
||||||
"gnocchi": "Gnocchi",
|
|
||||||
"crema mani": "Handcreme",
|
|
||||||
"erba cipollina": "Schnittlauch",
|
|
||||||
"snack & dolci": "Snacks & Süsswaren",
|
|
||||||
"pelati": "Pelati",
|
|
||||||
"bastoncini di pesce": "Fischstäbli",
|
|
||||||
"margarina": "Margarine",
|
|
||||||
"carne & pesce": "Fleisch & Fisch",
|
|
||||||
"sigarette": "Zigaretten",
|
|
||||||
"origano": "Oregano",
|
|
||||||
"riso basmati": "Basmatireis",
|
|
||||||
"filo interdentale": "Zahnseide",
|
|
||||||
"tofu": "Tofu",
|
|
||||||
"energy drink": "Energy Drink",
|
|
||||||
"peperoni": "Peperoni",
|
|
||||||
"sciroppo": "Sirup",
|
|
||||||
"fichi": "Feigen",
|
|
||||||
"nocciole": "Haselnüsse",
|
|
||||||
"farina": "Mehl",
|
|
||||||
"avena": "Haferflocken",
|
|
||||||
"composta di mele": "Apfelmus",
|
|
||||||
"riso": "Reis",
|
|
||||||
"mascarpone": "Mascarpone",
|
|
||||||
"tosaerba": "Rasenmäher",
|
|
||||||
"scaloppine": "Schnitzel",
|
|
||||||
"griglia": "Grill",
|
|
||||||
"ketchup": "Ketchup",
|
|
||||||
"salmone": "Lachs",
|
|
||||||
"cipolle": "Zwiebeln",
|
|
||||||
"bacche": "Beeren",
|
|
||||||
"cerotti": "Pflaster",
|
|
||||||
"mangime per pesci": "Fischfutter",
|
|
||||||
"candele": "Kerzen",
|
|
||||||
"frutta": "Früchte",
|
|
||||||
"carne di vitello": "Kalbfleisch",
|
|
||||||
"schiuma da barba": "Rasierschaum",
|
|
||||||
"zenzero": "Ingwer",
|
|
||||||
"pesca": "Pfirsich",
|
|
||||||
"panna acidula": "Sauerrahm",
|
|
||||||
"frutta & verdura": "Früchte & Gemüse",
|
|
||||||
"lasagne": "Lasagne",
|
|
||||||
"pennello": "Pinsel",
|
|
||||||
"lievito": "Hefe",
|
|
||||||
"torta": "Kuchen",
|
|
||||||
"prosecco": "Prosecco",
|
|
||||||
"assorbenti": "Tampons",
|
|
||||||
"pesce tonno": "Thunfisch",
|
|
||||||
"zucchero": "Zucker",
|
|
||||||
"piadina": "Piadina",
|
|
||||||
"petto di pollo": "Pouletbrüstli",
|
|
||||||
"castagne": "Kastanien",
|
|
||||||
"cavolfiore": "Blumenkohl",
|
|
||||||
"insalata": "Salat",
|
|
||||||
"carne": "Fleisch",
|
|
||||||
"cereali colazione": "Corn Flakes",
|
|
||||||
"merendina": "Merendina",
|
|
||||||
"aglio": "Knoblauch",
|
|
||||||
"carote": "Karotten",
|
|
||||||
"toast": "Toast",
|
|
||||||
"detersivo lavatrice": "Waschmittel",
|
|
||||||
"condimento insalata": "Salatsauce",
|
|
||||||
"cibo per cani": "Hundefutter",
|
|
||||||
"zucchero vanigliato": "Vanillezucker",
|
|
||||||
"collutorio": "Mundspülung",
|
|
||||||
"alimenti bimbi": "Babynahrung",
|
|
||||||
"pannolini": "Windeln",
|
|
||||||
"preservativo": "Kondome",
|
|
||||||
"couscous": "Couscous",
|
|
||||||
"brillantante": "Geschirrglanz",
|
|
||||||
"albicocche": "Aprikosen",
|
|
||||||
"lamponi": "Himbeeren",
|
|
||||||
"olive": "Oliven",
|
|
||||||
"panpepato": "Lebkuchen",
|
|
||||||
"pasta, riso & cereali": "Getreideprodukte",
|
|
||||||
"zucca": "Kürbis",
|
|
||||||
"tonic": "Tonic Water",
|
|
||||||
"pesche noci": "Nektarine",
|
|
||||||
"penne": "Penne",
|
|
||||||
"shampoo": "Shampoo",
|
|
||||||
"whisky": "Whisky",
|
|
||||||
"datteri": "Datteln",
|
|
||||||
"cacao": "Kakao",
|
|
||||||
"olio d'oliva": "Olivenöl",
|
|
||||||
"fagioli": "Bohnen",
|
|
||||||
"pizza": "Pizza",
|
|
||||||
"kiwi": "Kiwi",
|
|
||||||
"pollo": "Poulet",
|
|
||||||
"acqua": "Wasser",
|
|
||||||
"pasta": "Pasta",
|
|
||||||
"latte": "Milch",
|
|
||||||
"ciliegie": "Kirschen",
|
|
||||||
"mandorle": "Mandeln",
|
|
||||||
"latte & formaggi": "Milch & Käse",
|
|
||||||
"ceci": "Kichererbsen",
|
|
||||||
"kleenex": "Kosmetiktücher",
|
|
||||||
"gomma da masticare": "Kaugummi",
|
|
||||||
"crema viso": "Gesichtscreme",
|
|
||||||
"pomodori secchi": "getrocknete Tomaten",
|
|
||||||
"champignons": "Champignons",
|
|
||||||
"cola light": "Cola Light",
|
|
||||||
"arance": "Orange",
|
|
||||||
"foglio di alluminio": "Alufolie",
|
|
||||||
"melone": "Melone",
|
|
||||||
"banane": "Bananen",
|
|
||||||
"spazzolino": "Zahnbürsten",
|
|
||||||
"cannella": "Zimt",
|
|
||||||
"mele": "Äpfel",
|
|
||||||
"cola": "Cola",
|
|
||||||
"brodo": "Bouillon",
|
|
||||||
"salvia": "Salbei",
|
|
||||||
"salsa di soia": "Soyasauce",
|
|
||||||
"prosciutto crudo": "Rohschinken",
|
|
||||||
"formaggio grattugiato": "Reibkäse",
|
|
||||||
"affettato": "Aufschnitt",
|
|
||||||
"past. lavastoviglie": "Geschirrtabs",
|
|
||||||
"ombrellone": "Sonnenschirm",
|
|
||||||
"bresaola": "Bresaola",
|
|
||||||
"acqua minerale": "Mineralwasser",
|
|
||||||
"fazzoletti": "Taschentücher",
|
|
||||||
"casa & igiene": "Haushalt & Gesundheit",
|
|
||||||
"salviette": "Feuchttücher",
|
|
||||||
"piselli": "Erbsen",
|
|
||||||
"parmigiano": "Parmesan",
|
|
||||||
"crema gianduia": "Nougatcreme",
|
|
||||||
"pancetta": "Speck",
|
|
||||||
"animali": "Tierbedarf",
|
|
||||||
"avocado": "Avocado",
|
|
||||||
"paprica": "Paprikapulver",
|
|
||||||
"sacchi della spazzatura": "Abfallsäcke",
|
|
||||||
"aceto": "Essig",
|
|
||||||
"concime": "Dünger",
|
|
||||||
"funghi": "Pilze",
|
|
||||||
"batterie": "Batterien",
|
|
||||||
"sugo di pomodoro": "Tomatensauce",
|
|
||||||
"rucola": "Rucola",
|
|
||||||
"birra": "Bier",
|
|
||||||
"terriccio": "Blumenerde",
|
|
||||||
"rabarbaro": "Rhabarber",
|
|
||||||
"carciofi": "Artischocken",
|
|
||||||
"rosmarino": "Rosmarin",
|
|
||||||
"tonno": "Thon",
|
|
||||||
"soluzione lenti": "Linsenmittel",
|
|
||||||
"acetone": "Nagellackentferner",
|
|
||||||
"crema corpo": "Bodylotion",
|
|
||||||
"succo di mela": "Apfelsaft",
|
|
||||||
"biscotti di natale": "Guetzli",
|
|
||||||
"concentrato pomodoro": "Tomatenmark",
|
|
||||||
"cetriolo": "Gurke",
|
|
||||||
"carbonella": "Holzkohle",
|
|
||||||
"basilico": "Basilikum",
|
|
||||||
"yogurt": "Joghurt",
|
|
||||||
"bevande & tabacco": "Getränke & Tabak",
|
|
||||||
"popcorn": "Pop Corn",
|
|
||||||
"ammorbidente": "Weichspüler",
|
|
||||||
"burro": "Butter",
|
|
||||||
"vino rosso": "Rotwein",
|
|
||||||
"luganega": "Frankfurter",
|
|
||||||
"grappa": "Schnaps",
|
|
||||||
"pomodori": "Tomaten",
|
|
||||||
"ricotta": "Ricotta",
|
|
||||||
"dischetti di cotone": "Watterondellen",
|
|
||||||
"fragole": "Erdbeeren",
|
|
||||||
"cibo per uccelli": "Vogelfutter",
|
|
||||||
"timo": "Thymian",
|
|
||||||
"zucchero a velo": "Puderzucker",
|
|
||||||
"burro alle erbe": "Kräuterbutter",
|
|
||||||
"cachi": "Kaki",
|
|
||||||
"arachidi": "Erdnüsse",
|
|
||||||
"grani di pepe": "Pfefferkörner",
|
|
||||||
"viti": "Schrauben",
|
|
||||||
"acciughe": "Sardellen",
|
|
||||||
"carne di manzo": "Rindfleisch",
|
|
||||||
"balsamo": "Conditioner",
|
|
||||||
"pasta per pizza": "Pizzateig",
|
|
||||||
"limone": "Zitrone",
|
|
||||||
"chiodi": "Nägel",
|
|
||||||
"peperoncini": "Peperoncini",
|
|
||||||
"senape": "Senf",
|
|
||||||
"panini": "Brötchen",
|
|
||||||
"noci": "Baumnüsse",
|
|
||||||
"tagliatelle": "Nudeln",
|
|
||||||
"salsiccia": "Wurst",
|
|
||||||
"pudding": "Pudding",
|
|
||||||
"semolino": "Griess",
|
|
||||||
"mandarini": "Mandarinen",
|
|
||||||
"vino bianco": "Weisswein",
|
|
||||||
"pasta sfoglia": "Blätterteig",
|
|
||||||
"pomodorini": "Cherrytomaten",
|
|
||||||
"menta": "Pfefferminze",
|
|
||||||
"sabbia gatti": "Katzenstreu",
|
|
||||||
"prugne": "Zwetschgen",
|
|
||||||
"more": "Brombeeren",
|
|
||||||
"gin": "Gin",
|
|
||||||
"vodka": "Vodka",
|
|
||||||
"miele": "Honig",
|
|
||||||
"carta igienica": "WC-Papier",
|
|
||||||
"panetteria": "Brot & Gebäck",
|
|
||||||
"pangrattato": "Paniermehl",
|
|
||||||
"detersivo piatti": "Abwaschmittel",
|
|
||||||
"panna": "Rahm",
|
|
||||||
"maionese": "Mayonnaise",
|
|
||||||
"detersivo": "Spülmittel",
|
|
||||||
"sedano": "Sellerie",
|
|
||||||
"porro": "Lauch",
|
|
||||||
"sminuzzato manzo": "Rindsgeschnetzeltes",
|
|
||||||
"detergente per wc": "WC-Reiniger",
|
|
||||||
"baguette": "Baguette",
|
|
||||||
"marmellata": "Konfitüre",
|
|
||||||
"analgesico": "Schmerzmittel",
|
|
||||||
"pulizia bagno": "Badreiniger",
|
|
||||||
"mango": "Mango",
|
|
||||||
"mozzarella": "Mozzarella",
|
|
||||||
"ananas": "Ananas",
|
|
||||||
"propano": "Propangas",
|
|
||||||
"salsa per arrosto": "Bratensauce",
|
|
||||||
"orecchiette": "Orecchiette",
|
|
||||||
"agnello": "Lamm",
|
|
||||||
"pellicole": "Frischhaltefolie",
|
|
||||||
"dentifricio": "Zahnpasta",
|
|
||||||
"spaghetti": "Spaghetti",
|
|
||||||
"gel styling": "Haargel",
|
|
||||||
"snack": "Snacks",
|
|
||||||
"prezzemolo": "Petersilie",
|
|
||||||
"pompelmo": "Grapefruit",
|
|
||||||
"grana padano": "Grana Padano",
|
|
||||||
"tovaglioli": "Servietten",
|
|
||||||
"vasi": "Töpfe",
|
|
||||||
"lenticchie": "Linsen",
|
|
||||||
"crema doccia": "Duschmittel",
|
|
||||||
"gorgonzola": "Gorgonzola",
|
|
||||||
"spinaci": "Spinat",
|
|
||||||
"bicarbonato": "Backpulver",
|
|
||||||
"risotto": "Risottoreis",
|
|
||||||
"rasoio": "Rasierer",
|
|
||||||
"patate fritte": "Pommes Frites",
|
|
||||||
"deodorante": "Deo",
|
|
||||||
"piante": "Pflanzen",
|
|
||||||
"cibo per gatti": "Katzenfutter",
|
|
||||||
"carta da regalo": "Geschenkpapier",
|
|
||||||
"tè": "Tee",
|
|
||||||
"bastoncini cotonati": "Wattestäbchen",
|
|
||||||
"erbe": "Kräuter",
|
|
||||||
"sapone": "Seife",
|
|
||||||
"gelato": "Glacé",
|
|
||||||
"mais": "Mais",
|
|
||||||
"carta domestica": "Haushaltspapier",
|
|
||||||
"polenta": "Polenta",
|
|
||||||
"tuoi articoli": "Eigene Artikel",
|
|
||||||
"utilizzato per ultimo": "Zuletzt verwendet",
|
|
||||||
"aroma": "Zutaten & Gewürze",
|
|
||||||
"ingredienti spezie": "Zutaten & Gewürze",
|
|
||||||
"bevande": "Getränke & Tabak",
|
|
||||||
"camomilla": "Tee",
|
|
||||||
"cioccolata calda": "Kakao",
|
|
||||||
"cipolla": "Zwiebeln",
|
|
||||||
"cracker": "Snacks & Süsswaren",
|
|
||||||
"farina integrale": "Mehl",
|
|
||||||
"fette biscottate": "Toast",
|
|
||||||
"filetto": "Fleisch",
|
|
||||||
"liquore": "Getränke & Tabak",
|
|
||||||
"muesli": "Corn Flakes",
|
|
||||||
"panna da cucina": "Rahm",
|
|
||||||
"passata": "Pelati",
|
|
||||||
"piatti pronti": "Fertig- & Tiefkühlprodukte",
|
|
||||||
"polpa di pomodoro": "Pelati",
|
|
||||||
"purè": "Fertig- & Tiefkühlprodukte",
|
|
||||||
"salsa": "Zutaten & Gewürze",
|
|
||||||
"sfornatini": "Fertig- & Tiefkühlprodukte",
|
|
||||||
"snack dolci": "Snacks & Süsswaren",
|
|
||||||
"succo": "Fruchtsaft",
|
|
||||||
"taralli": "Snacks & Süsswaren",
|
|
||||||
"vino": "Rotwein",
|
|
||||||
"zucchero di canna": "Zucker"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"ts":1777391782}
|
|
||||||
+76
-1
@@ -630,6 +630,17 @@
|
|||||||
|
|
||||||
<!-- 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 summary bar (shown when prices are enabled) -->
|
||||||
|
<div id="shopping-price-bar" style="display:none">
|
||||||
|
<div class="shopping-price-total-row">
|
||||||
|
<span class="price-total-label" data-i18n="shopping.price_total_label">💰 Spesa stimata:</span>
|
||||||
|
<span class="price-total-value" id="price-total-value">–</span>
|
||||||
|
<button class="btn-price-refresh" id="btn-price-refresh" onclick="fetchAllPrices(false)" title="Aggiorna prezzi">🔄</button>
|
||||||
|
</div>
|
||||||
|
<div id="price-loading-bar" style="display:none" class="price-loading-bar">
|
||||||
|
<div class="price-loading-inner"></div>
|
||||||
|
</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>
|
||||||
@@ -651,11 +662,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="shopping-actions">
|
<div class="shopping-actions">
|
||||||
<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>
|
||||||
<button class="btn btn-secondary" onclick="forceSyncBring()" style="margin-top:4px" data-i18n="shopping.force_sync">
|
<button class="btn btn-secondary" onclick="forceSyncBring()" style="margin-top:4px" data-i18n="shopping.force_sync">
|
||||||
🔄 Forza sincronizzazione Bring!
|
🔄 Forza sincronizzazione Bring!
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary" id="btn-fetch-prices" onclick="fetchAllPrices(false)" style="margin-top:4px;display:none" data-i18n="shopping.btn_fetch_prices">
|
||||||
|
Cerca i prezzi
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -785,6 +799,67 @@
|
|||||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')">👁️ Mostra/Nascondi</button>
|
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')">👁️ Mostra/Nascondi</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Price Estimation Settings -->
|
||||||
|
<div class="settings-card" style="margin-top:12px">
|
||||||
|
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.price.hint">Mostra il costo stimato di ogni prodotto nella lista della spesa usando l'AI.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.price.enabled_label">Attiva stima prezzi</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-price-enabled">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="price-settings-sub" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.price.country_label">🌍 Paese di riferimento</label>
|
||||||
|
<select id="setting-price-country" class="form-input" onchange="onPriceCountryChange()">
|
||||||
|
<option value="Italia">🇮🇹 Italia</option>
|
||||||
|
<option value="USA">🇺🇸 USA</option>
|
||||||
|
<option value="Germany">🇩🇪 Germania</option>
|
||||||
|
<option value="France">🇫🇷 Francia</option>
|
||||||
|
<option value="Spain">🇪🇸 Spagna</option>
|
||||||
|
<option value="UK">🇬🇧 Regno Unito</option>
|
||||||
|
<option value="Switzerland">🇨🇭 Svizzera</option>
|
||||||
|
<option value="Austria">🇦🇹 Austria</option>
|
||||||
|
<option value="Netherlands">🇳🇱 Olanda</option>
|
||||||
|
<option value="Belgium">🇧🇪 Belgio</option>
|
||||||
|
<option value="Canada">🇨🇦 Canada</option>
|
||||||
|
<option value="Australia">🇦🇺 Australia</option>
|
||||||
|
<option value="Brazil">🇧🇷 Brasile</option>
|
||||||
|
<option value="Japan">🇯🇵 Giappone</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.price.currency_label">💱 Valuta</label>
|
||||||
|
<select id="setting-price-currency" class="form-input">
|
||||||
|
<option value="EUR">€ Euro (EUR)</option>
|
||||||
|
<option value="USD">$ Dollaro USA (USD)</option>
|
||||||
|
<option value="GBP">£ Sterlina (GBP)</option>
|
||||||
|
<option value="CHF">CHF Franco Svizzero</option>
|
||||||
|
<option value="CAD">CA$ Dollaro Canadese</option>
|
||||||
|
<option value="AUD">A$ Dollaro Australiano</option>
|
||||||
|
<option value="BRL">R$ Real Brasiliano</option>
|
||||||
|
<option value="JPY">¥ Yen Giapponese</option>
|
||||||
|
<option value="SEK">kr Corona Svedese</option>
|
||||||
|
<option value="NOK">kr Corona Norvegese</option>
|
||||||
|
<option value="DKK">kr Corona Danese</option>
|
||||||
|
<option value="PLN">zł Zloty Polacco</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.price.update_label">🔄 Aggiorna prezzi ogni</label>
|
||||||
|
<div class="qty-control">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('setting-price-update-months', -1, 1, 24)">−</button>
|
||||||
|
<input type="number" id="setting-price-update-months" value="3" min="1" max="24" class="qty-input">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('setting-price-update-months', 1, 1, 24)">+</button>
|
||||||
|
</div>
|
||||||
|
<span class="settings-hint" style="display:inline;margin-left:8px" data-i18n="settings.price.update_suffix">mesi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Recipe Tab -->
|
<!-- Recipe Tab -->
|
||||||
<div class="settings-panel" id="tab-recipe">
|
<div class="settings-panel" id="tab-recipe">
|
||||||
|
|||||||
+22
-6
@@ -103,11 +103,13 @@
|
|||||||
"banner_expired_action_throw": "Habe ich weggeworfen",
|
"banner_expired_action_throw": "Habe ich weggeworfen",
|
||||||
"banner_expired_action_edit": "Datum korrigieren",
|
"banner_expired_action_edit": "Datum korrigieren",
|
||||||
"banner_anomaly_action_edit": "Bestand korrigieren",
|
"banner_anomaly_action_edit": "Bestand korrigieren",
|
||||||
"banner_anomaly_action_dismiss": "Menge ist korrekt", "banner_no_expiry_title": "Ablaufdatum fehlt: {name}",
|
"banner_anomaly_action_dismiss": "Menge ist korrekt",
|
||||||
|
"banner_no_expiry_title": "Ablaufdatum fehlt: {name}",
|
||||||
"banner_no_expiry_detail": "Dieses Produkt hat kein Ablaufdatum. Möchten Sie eines hinzufügen oder bestätigen, dass es nicht verfällt?",
|
"banner_no_expiry_detail": "Dieses Produkt hat kein Ablaufdatum. Möchten Sie eines hinzufügen oder bestätigen, dass es nicht verfällt?",
|
||||||
"banner_no_expiry_action_set": "Ablaufdatum setzen",
|
"banner_no_expiry_action_set": "Ablaufdatum setzen",
|
||||||
"banner_no_expiry_action_dismiss": "Läuft nicht ab ✓",
|
"banner_no_expiry_action_dismiss": "Läuft nicht ab ✓",
|
||||||
"banner_no_expiry_toast_dismissed": "Als 'läuft nicht ab' markiert", "banner_expiring_title": "Bald ablaufend",
|
"banner_no_expiry_toast_dismissed": "Als 'läuft nicht ab' markiert",
|
||||||
|
"banner_expiring_title": "Bald ablaufend",
|
||||||
"banner_expiring_today": "Läuft heute ab!",
|
"banner_expiring_today": "Läuft heute ab!",
|
||||||
"banner_expiring_tomorrow": "Läuft morgen ab",
|
"banner_expiring_tomorrow": "Läuft morgen ab",
|
||||||
"banner_expiring_days": "Läuft in {days} Tagen ab",
|
"banner_expiring_days": "Läuft in {days} Tagen ab",
|
||||||
@@ -133,8 +135,7 @@
|
|||||||
"banner_anomaly_phantom_title": "mehr Bestand als erwartet",
|
"banner_anomaly_phantom_title": "mehr Bestand als erwartet",
|
||||||
"banner_anomaly_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?",
|
"banner_anomaly_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?",
|
||||||
"banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht",
|
"banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht",
|
||||||
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach."
|
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach.",
|
||||||
,
|
|
||||||
"banner_anomaly_ghost_title": "weniger Bestand als erwartet",
|
"banner_anomaly_ghost_title": "weniger Bestand als erwartet",
|
||||||
"banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?",
|
"banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?",
|
||||||
"consumed": "Verbraucht: {n} ({pct}%)",
|
"consumed": "Verbraucht: {n} ({pct}%)",
|
||||||
@@ -161,7 +162,8 @@
|
|||||||
"label_quantity": "📦 Menge",
|
"label_quantity": "📦 Menge",
|
||||||
"label_added": "📅 Hinzugefügt",
|
"label_added": "📅 Hinzugefügt",
|
||||||
"empty_text": "Keine Produkte hier.<br>Scanne ein Produkt, um es hinzuzufügen!",
|
"empty_text": "Keine Produkte hier.<br>Scanne ein Produkt, um es hinzuzufügen!",
|
||||||
"empty_db": "Keine Produkte in der Datenbank.<br>Scanne ein Produkt, um loszulegen!"
|
"empty_db": "Keine Produkte in der Datenbank.<br>Scanne ein Produkt, um loszulegen!",
|
||||||
|
"qty_trace": "< 1"
|
||||||
},
|
},
|
||||||
"scan": {
|
"scan": {
|
||||||
"title": "Produkt scannen",
|
"title": "Produkt scannen",
|
||||||
@@ -344,7 +346,7 @@
|
|||||||
"suggestions_title": "💡 KI-Vorschläge",
|
"suggestions_title": "💡 KI-Vorschläge",
|
||||||
"suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen",
|
"suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen",
|
||||||
"search_prices": "🔍 Alle Preise suchen",
|
"search_prices": "🔍 Alle Preise suchen",
|
||||||
"suggest_btn": "🤖 Einkaufsvorschläge",
|
"suggest_btn": "Einkaufsvorschläge",
|
||||||
"smart_title": "🧠 Intelligente Vorhersagen",
|
"smart_title": "🧠 Intelligente Vorhersagen",
|
||||||
"smart_empty": "Keine Vorhersagen verfügbar.<br>Füge Produkte zur Vorratskammer hinzu, um intelligente Vorhersagen zu erhalten.",
|
"smart_empty": "Keine Vorhersagen verfügbar.<br>Füge Produkte zur Vorratskammer hinzu, um intelligente Vorhersagen zu erhalten.",
|
||||||
"smart_filter_all": "Alle",
|
"smart_filter_all": "Alle",
|
||||||
@@ -416,6 +418,10 @@
|
|||||||
"savings_offers": "· 🏷️ Du sparst €{amount} mit Angeboten",
|
"savings_offers": "· 🏷️ Du sparst €{amount} mit Angeboten",
|
||||||
"searching_progress": "Suche {current}/{total}...",
|
"searching_progress": "Suche {current}/{total}...",
|
||||||
"remove_error": "Fehler beim Entfernen",
|
"remove_error": "Fehler beim Entfernen",
|
||||||
|
"btn_fetch_prices": "Preise suchen",
|
||||||
|
"price_total_label": "💰 Geschätzter Gesamtpreis:",
|
||||||
|
"price_loading": "Preise werden gesucht…",
|
||||||
|
"price_not_found": "Preis n/v",
|
||||||
"suggest_loading": "Analyse läuft...",
|
"suggest_loading": "Analyse läuft...",
|
||||||
"suggest_error": "Fehler bei der Vorschlagserstellung",
|
"suggest_error": "Fehler bei der Vorschlagserstellung",
|
||||||
"priority_high": "Hoch",
|
"priority_high": "Hoch",
|
||||||
@@ -508,6 +514,15 @@
|
|||||||
"email_label": "📧 Bring! E-Mail",
|
"email_label": "📧 Bring! E-Mail",
|
||||||
"password_label": "🔒 Bring! Passwort"
|
"password_label": "🔒 Bring! Passwort"
|
||||||
},
|
},
|
||||||
|
"price": {
|
||||||
|
"title": "💰 Preisschätzung (KI)",
|
||||||
|
"hint": "Zeigt geschätzte Kosten pro Produkt in der Einkaufsliste mithilfe von KI an.",
|
||||||
|
"enabled_label": "Preisschätzung aktivieren",
|
||||||
|
"country_label": "🌍 Referenzland",
|
||||||
|
"currency_label": "💱 Währung",
|
||||||
|
"update_label": "🔄 Preise aktualisieren alle",
|
||||||
|
"update_suffix": "Monate"
|
||||||
|
},
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"title": "🍳 Rezept-Einstellungen",
|
"title": "🍳 Rezept-Einstellungen",
|
||||||
"hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.",
|
"hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.",
|
||||||
@@ -906,6 +921,7 @@
|
|||||||
},
|
},
|
||||||
"meal_plan": {
|
"meal_plan": {
|
||||||
"reset_success": "Wochenplan zurückgesetzt",
|
"reset_success": "Wochenplan zurückgesetzt",
|
||||||
|
"not_available": "nicht im Vorrat verfügbar",
|
||||||
"suggested_by": "vom Wochenplan vorgeschlagen"
|
"suggested_by": "vom Wochenplan vorgeschlagen"
|
||||||
},
|
},
|
||||||
"kiosk_session": {
|
"kiosk_session": {
|
||||||
|
|||||||
+17
-2
@@ -162,7 +162,8 @@
|
|||||||
"label_quantity": "📦 Quantity",
|
"label_quantity": "📦 Quantity",
|
||||||
"label_added": "📅 Added",
|
"label_added": "📅 Added",
|
||||||
"empty_text": "No products here.<br>Scan a product to add it!",
|
"empty_text": "No products here.<br>Scan a product to add it!",
|
||||||
"empty_db": "No products in the database.<br>Scan a product to get started!"
|
"empty_db": "No products in the database.<br>Scan a product to get started!",
|
||||||
|
"qty_trace": "< 1"
|
||||||
},
|
},
|
||||||
"scan": {
|
"scan": {
|
||||||
"title": "Scan Product",
|
"title": "Scan Product",
|
||||||
@@ -345,7 +346,7 @@
|
|||||||
"suggestions_title": "💡 AI Suggestions",
|
"suggestions_title": "💡 AI Suggestions",
|
||||||
"suggestions_add": "✅ Add selected to Bring!",
|
"suggestions_add": "✅ Add selected to Bring!",
|
||||||
"search_prices": "🔍 Search all prices",
|
"search_prices": "🔍 Search all prices",
|
||||||
"suggest_btn": "🤖 Suggest what to buy",
|
"suggest_btn": "Suggest what to buy",
|
||||||
"smart_title": "🧠 Smart Predictions",
|
"smart_title": "🧠 Smart Predictions",
|
||||||
"smart_empty": "No predictions available.<br>Add products to your pantry to receive smart predictions.",
|
"smart_empty": "No predictions available.<br>Add products to your pantry to receive smart predictions.",
|
||||||
"smart_filter_all": "All",
|
"smart_filter_all": "All",
|
||||||
@@ -417,6 +418,10 @@
|
|||||||
"savings_offers": "· 🏷️ You save €{amount} with offers",
|
"savings_offers": "· 🏷️ You save €{amount} with offers",
|
||||||
"searching_progress": "Searching {current}/{total}...",
|
"searching_progress": "Searching {current}/{total}...",
|
||||||
"remove_error": "Removal error",
|
"remove_error": "Removal error",
|
||||||
|
"btn_fetch_prices": "Find prices",
|
||||||
|
"price_total_label": "💰 Estimated total:",
|
||||||
|
"price_loading": "Looking up prices…",
|
||||||
|
"price_not_found": "price n/a",
|
||||||
"suggest_loading": "Analyzing...",
|
"suggest_loading": "Analyzing...",
|
||||||
"suggest_error": "Suggestion generation error",
|
"suggest_error": "Suggestion generation error",
|
||||||
"priority_high": "High",
|
"priority_high": "High",
|
||||||
@@ -509,6 +514,15 @@
|
|||||||
"email_label": "📧 Bring! Email",
|
"email_label": "📧 Bring! Email",
|
||||||
"password_label": "🔒 Bring! Password"
|
"password_label": "🔒 Bring! Password"
|
||||||
},
|
},
|
||||||
|
"price": {
|
||||||
|
"title": "💰 Price Estimation (AI)",
|
||||||
|
"hint": "Show estimated cost per product in the shopping list using AI.",
|
||||||
|
"enabled_label": "Enable price estimation",
|
||||||
|
"country_label": "🌍 Reference country",
|
||||||
|
"currency_label": "💱 Currency",
|
||||||
|
"update_label": "🔄 Refresh prices every",
|
||||||
|
"update_suffix": "months"
|
||||||
|
},
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"title": "🍳 Recipe Preferences",
|
"title": "🍳 Recipe Preferences",
|
||||||
"hint": "Configure the default options for recipe generation.",
|
"hint": "Configure the default options for recipe generation.",
|
||||||
@@ -907,6 +921,7 @@
|
|||||||
},
|
},
|
||||||
"meal_plan": {
|
"meal_plan": {
|
||||||
"reset_success": "Weekly plan reset",
|
"reset_success": "Weekly plan reset",
|
||||||
|
"not_available": "not available in pantry",
|
||||||
"suggested_by": "suggested by weekly plan"
|
"suggested_by": "suggested by weekly plan"
|
||||||
},
|
},
|
||||||
"kiosk_session": {
|
"kiosk_session": {
|
||||||
|
|||||||
+18
-3
@@ -162,7 +162,8 @@
|
|||||||
"label_quantity": "📦 Quantità",
|
"label_quantity": "📦 Quantità",
|
||||||
"label_added": "📅 Aggiunto",
|
"label_added": "📅 Aggiunto",
|
||||||
"empty_text": "Nessun prodotto qui.<br>Scansiona un prodotto per aggiungerlo!",
|
"empty_text": "Nessun prodotto qui.<br>Scansiona un prodotto per aggiungerlo!",
|
||||||
"empty_db": "Nessun prodotto nel database.<br>Scansiona un prodotto per iniziare!"
|
"empty_db": "Nessun prodotto nel database.<br>Scansiona un prodotto per iniziare!",
|
||||||
|
"qty_trace": "< 1"
|
||||||
},
|
},
|
||||||
"scan": {
|
"scan": {
|
||||||
"title": "Scansiona Prodotto",
|
"title": "Scansiona Prodotto",
|
||||||
@@ -345,7 +346,7 @@
|
|||||||
"suggestions_title": "💡 Suggerimenti AI",
|
"suggestions_title": "💡 Suggerimenti AI",
|
||||||
"suggestions_add": "✅ Aggiungi selezionati a Bring!",
|
"suggestions_add": "✅ Aggiungi selezionati a Bring!",
|
||||||
"search_prices": "🔍 Cerca tutti i prezzi",
|
"search_prices": "🔍 Cerca tutti i prezzi",
|
||||||
"suggest_btn": "🤖 Suggerisci cosa comprare",
|
"suggest_btn": "Suggerisci cosa comprare",
|
||||||
"smart_title": "🧠 Previsioni intelligenti",
|
"smart_title": "🧠 Previsioni intelligenti",
|
||||||
"smart_empty": "Nessuna previsione disponibile.<br>Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.",
|
"smart_empty": "Nessuna previsione disponibile.<br>Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.",
|
||||||
"smart_filter_all": "Tutti",
|
"smart_filter_all": "Tutti",
|
||||||
@@ -417,6 +418,10 @@
|
|||||||
"savings_offers": "· 🏷️ Risparmi €{amount} con le offerte",
|
"savings_offers": "· 🏷️ Risparmi €{amount} con le offerte",
|
||||||
"searching_progress": "Cerco {current}/{total}...",
|
"searching_progress": "Cerco {current}/{total}...",
|
||||||
"remove_error": "Errore nella rimozione",
|
"remove_error": "Errore nella rimozione",
|
||||||
|
"btn_fetch_prices": "Cerca i prezzi",
|
||||||
|
"price_total_label": "💰 Spesa stimata:",
|
||||||
|
"price_loading": "Ricerca prezzi…",
|
||||||
|
"price_not_found": "prezzo n/d",
|
||||||
"suggest_loading": "Analisi in corso...",
|
"suggest_loading": "Analisi in corso...",
|
||||||
"suggest_error": "Errore nella generazione",
|
"suggest_error": "Errore nella generazione",
|
||||||
"priority_high": "Alta",
|
"priority_high": "Alta",
|
||||||
@@ -509,6 +514,15 @@
|
|||||||
"email_label": "📧 Email Bring!",
|
"email_label": "📧 Email Bring!",
|
||||||
"password_label": "🔒 Password Bring!"
|
"password_label": "🔒 Password Bring!"
|
||||||
},
|
},
|
||||||
|
"price": {
|
||||||
|
"title": "💰 Stima Prezzi (AI)",
|
||||||
|
"hint": "Mostra il costo stimato di ogni prodotto nella lista della spesa usando l'AI.",
|
||||||
|
"enabled_label": "Attiva stima prezzi",
|
||||||
|
"country_label": "🌍 Paese di riferimento",
|
||||||
|
"currency_label": "💱 Valuta",
|
||||||
|
"update_label": "🔄 Aggiorna prezzi ogni",
|
||||||
|
"update_suffix": "mesi"
|
||||||
|
},
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"title": "🍳 Preferenze Ricette",
|
"title": "🍳 Preferenze Ricette",
|
||||||
"hint": "Configura le opzioni predefinite per la generazione delle ricette.",
|
"hint": "Configura le opzioni predefinite per la generazione delle ricette.",
|
||||||
@@ -907,7 +921,8 @@
|
|||||||
},
|
},
|
||||||
"meal_plan": {
|
"meal_plan": {
|
||||||
"reset_success": "Piano settimanale ripristinato",
|
"reset_success": "Piano settimanale ripristinato",
|
||||||
"suggested_by": "suggerito dal piano settimanale"
|
"suggested_by": "suggerito dal piano settimanale",
|
||||||
|
"not_available": "non disponibile in dispensa"
|
||||||
},
|
},
|
||||||
"kiosk_session": {
|
"kiosk_session": {
|
||||||
"first_item": "Primo prodotto: {name}!",
|
"first_item": "Primo prodotto: {name}!",
|
||||||
|
|||||||
Reference in New Issue
Block a user