Complete i18n pass for recipes and meal plan labels

This commit is contained in:
dadaloop82
2026-04-28 17:28:54 +00:00
parent 8722f15aa0
commit 8558db1925
7 changed files with 645 additions and 249 deletions
+6
View File
@@ -12,6 +12,12 @@
--- ---
## 🌍 Recent i18n Updates
- Recipe and meal-plan labels now resolve at runtime from translations, preventing raw placeholders like `meal_types.*` and `meal_plan_types.*` from appearing in the UI.
- Recipe generation now receives the selected app language (`it`/`en`/`de`) and enforces localized output in both streaming and non-streaming API flows.
- Added missing shared error keys (`error.network`, `error.no_api_key`) across all language files to keep fallback/error toasts fully translated.
## ✨ Features ## ✨ Features
### 📦 Inventory Management ### 📦 Inventory Management
+128 -24
View File
@@ -2203,6 +2203,95 @@ PROMPT;
echo json_encode(['success' => true, 'reply' => $reply]); echo json_encode(['success' => true, 'reply' => $reply]);
} }
function recipeNormalizeLang($lang): string {
$lang = is_string($lang) ? strtolower(trim($lang)) : 'it';
return in_array($lang, ['it', 'en', 'de'], true) ? $lang : 'it';
}
function recipeLangName(string $lang): string {
return [
'it' => 'Italian',
'en' => 'English',
'de' => 'German',
][$lang] ?? 'Italian';
}
function recipeText(string $lang, string $key, array $vars = []): string {
$dict = [
'it' => [
'status_analyze_pantry' => '📦 Analizzo la dispensa...',
'status_products_found' => '{n} prodotti trovati',
'status_passed_ai' => ' ({n} passati all\'AI)',
'status_all_passed_ai' => ' — tutti passati all\'AI',
'status_urgent' => '⚠️ {n} urgenti: {items}',
'status_evaluate_ingredients' => '🧠 Valuto gli ingredienti disponibili...',
'status_preparing_recipe' => '👨‍🍳 Preparo la ricetta...',
'status_recipe_with' => '🥘 Ricetta con {a} e {b}',
'status_variant' => ' — variante #{n}',
'status_dish_based_on' => '🎯 Piatto a base di {type}',
'status_creating_full_recipe' => '✍️ Creo la ricetta completa...',
'status_quota_wait' => '⏳ Quota TPM esaurita ({model}), attendo {s}s... (tentativo {a}/{m})',
'status_retry_generation' => '✍️ Riprovo la generazione...',
'status_switch_model' => '🔄 Cambio modello → {model}...',
'error_pantry_empty' => 'La dispensa è vuota!',
'error_gemini_api' => 'Errore API Gemini',
'error_cannot_generate' => 'Impossibile generare la ricetta',
'error_empty_reply' => 'Risposta vuota da Gemini',
'prompt_lang_rule' => 'IMPORTANTE: scrivi tutti i campi testuali della ricetta in Italiano.',
'prompt_step_example' => 'Passo 1…',
],
'en' => [
'status_analyze_pantry' => '📦 Analyzing pantry...',
'status_products_found' => '{n} products found',
'status_passed_ai' => ' ({n} sent to AI)',
'status_all_passed_ai' => ' — all sent to AI',
'status_urgent' => '⚠️ {n} urgent: {items}',
'status_evaluate_ingredients' => '🧠 Evaluating available ingredients...',
'status_preparing_recipe' => '👨‍🍳 Preparing recipe...',
'status_recipe_with' => '🥘 Recipe with {a} and {b}',
'status_variant' => ' — variation #{n}',
'status_dish_based_on' => '🎯 Dish based on {type}',
'status_creating_full_recipe' => '✍️ Creating full recipe...',
'status_quota_wait' => '⏳ TPM quota reached ({model}), waiting {s}s... (attempt {a}/{m})',
'status_retry_generation' => '✍️ Retrying generation...',
'status_switch_model' => '🔄 Switching model → {model}...',
'error_pantry_empty' => 'Pantry is empty!',
'error_gemini_api' => 'Gemini API error',
'error_cannot_generate' => 'Unable to generate recipe',
'error_empty_reply' => 'Empty response from Gemini',
'prompt_lang_rule' => 'IMPORTANT: write all textual recipe fields in English only. Do not use Italian or German.',
'prompt_step_example' => 'Step 1…',
],
'de' => [
'status_analyze_pantry' => '📦 Vorrat wird analysiert...',
'status_products_found' => '{n} Produkte gefunden',
'status_passed_ai' => ' ({n} an die KI gesendet)',
'status_all_passed_ai' => ' — alle an die KI gesendet',
'status_urgent' => '⚠️ {n} dringend: {items}',
'status_evaluate_ingredients' => '🧠 Verfuegbare Zutaten werden bewertet...',
'status_preparing_recipe' => '👨‍🍳 Rezept wird vorbereitet...',
'status_recipe_with' => '🥘 Rezept mit {a} und {b}',
'status_variant' => ' — Variante #{n}',
'status_dish_based_on' => '🎯 Gericht auf Basis von {type}',
'status_creating_full_recipe' => '✍️ Vollstaendiges Rezept wird erstellt...',
'status_quota_wait' => '⏳ TPM-Limit erreicht ({model}), warte {s}s... (Versuch {a}/{m})',
'status_retry_generation' => '✍️ Generierung wird erneut versucht...',
'status_switch_model' => '🔄 Modellwechsel → {model}...',
'error_pantry_empty' => 'Die Vorratskammer ist leer!',
'error_gemini_api' => 'Gemini-API-Fehler',
'error_cannot_generate' => 'Rezept konnte nicht erstellt werden',
'error_empty_reply' => 'Leere Antwort von Gemini',
'prompt_lang_rule' => 'WICHTIG: schreibe alle textuellen Rezeptfelder nur auf Deutsch. Verwende kein Italienisch oder Englisch.',
'prompt_step_example' => 'Schritt 1…',
],
];
$text = $dict[$lang][$key] ?? $dict['it'][$key] ?? $key;
foreach ($vars as $name => $value) {
$text = str_replace('{' . $name . '}', (string)$value, $text);
}
return $text;
}
// ===== RECIPE GENERATION WITH GEMINI ===== // ===== RECIPE GENERATION WITH GEMINI =====
function generateRecipe(PDO $db): void { function generateRecipe(PDO $db): void {
$apiKey = env('GEMINI_API_KEY'); $apiKey = env('GEMINI_API_KEY');
@@ -2212,6 +2301,8 @@ function generateRecipe(PDO $db): void {
} }
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
$lang = recipeNormalizeLang($input['lang'] ?? 'it');
$recipeLangName = recipeLangName($lang);
$mealType = $input['meal'] ?? 'pranzo'; $mealType = $input['meal'] ?? 'pranzo';
$persons = max(1, intval($input['persons'] ?? 1)); $persons = max(1, intval($input['persons'] ?? 1));
$subType = $input['sub_type'] ?? ''; $subType = $input['sub_type'] ?? '';
@@ -2235,7 +2326,7 @@ function generateRecipe(PDO $db): void {
$items = $stmt->fetchAll(PDO::FETCH_ASSOC); $items = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($items)) { if (empty($items)) {
echo json_encode(['success' => false, 'error' => 'La dispensa è vuota!']); echo json_encode(['success' => false, 'error' => recipeText($lang, 'error_pantry_empty')]);
return; return;
} }
@@ -2556,8 +2647,11 @@ function generateRecipe(PDO $db): void {
} }
} }
$promptLanguageRule = recipeText($lang, 'prompt_lang_rule');
$promptStepExample = recipeText($lang, 'prompt_step_example');
$prompt = <<<PROMPT $prompt = <<<PROMPT
Sei uno chef italiano esperto. Genera UNA ricetta per $mealLabel per $persons persona/e usando gli ingredienti disponibili sotto. You are an expert home chef. Generate ONE recipe for $mealLabel for $persons person(s) using the available ingredients below.
{$extraRulesText}{$appliancesText}{$dietaryText}{$subTypeText}{$mealPlanText}{$varietyText}{$regenText}{$mustUseText} {$extraRulesText}{$appliancesText}{$dietaryText}{$subTypeText}{$mealPlanText}{$varietyText}{$regenText}{$mustUseText}
REGOLE: REGOLE:
@@ -2567,12 +2661,14 @@ REGOLE:
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).
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged.
DISPENSA: DISPENSA:
$ingredientsText $ingredientsText
Rispondi SOLO JSON valido (no markdown): Rispondi SOLO JSON valido (no markdown):
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["Passo 1…"],"nutrition_note":"…"} {$promptLanguageRule}
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
PROMPT; PROMPT;
$payload = [ $payload = [
@@ -2594,7 +2690,7 @@ PROMPT;
if ($httpCode !== 200) { if ($httpCode !== 200) {
$errDetail = $result['data']['error']['message'] ?? substr($result['body'], 0, 300); $errDetail = $result['data']['error']['message'] ?? substr($result['body'], 0, 300);
echo json_encode(['success' => false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode, 'detail' => $errDetail]); echo json_encode(['success' => false, 'error' => recipeText($lang, 'error_gemini_api'), 'http_code' => $httpCode, 'detail' => $errDetail]);
return; return;
} }
@@ -2801,7 +2897,7 @@ PROMPT;
echo json_encode(['success' => true, 'recipe' => $recipe]); echo json_encode(['success' => true, 'recipe' => $recipe]);
} else { } else {
echo json_encode(['success' => false, 'error' => 'Impossibile generare la ricetta', 'raw' => $text]); echo json_encode(['success' => false, 'error' => recipeText($lang, 'error_cannot_generate'), 'raw' => $text]);
} }
} }
@@ -2825,6 +2921,8 @@ function generateRecipeStream(PDO $db): void {
if (empty($apiKey)) { $send('error', ['error' => 'no_api_key']); return; } if (empty($apiKey)) { $send('error', ['error' => 'no_api_key']); return; }
$input = json_decode(file_get_contents('php://input'), true) ?? []; $input = json_decode(file_get_contents('php://input'), true) ?? [];
$lang = recipeNormalizeLang($input['lang'] ?? 'it');
$recipeLangName = recipeLangName($lang);
$mealType = $input['meal'] ?? 'pranzo'; $mealType = $input['meal'] ?? 'pranzo';
$persons = max(1, intval($input['persons'] ?? 1)); $persons = max(1, intval($input['persons'] ?? 1));
$subType = $input['sub_type'] ?? ''; $subType = $input['sub_type'] ?? '';
@@ -2837,7 +2935,7 @@ function generateRecipeStream(PDO $db): void {
$rejectedIngredients = $input['rejected_ingredients'] ?? []; $rejectedIngredients = $input['rejected_ingredients'] ?? [];
// ── AGENTE PASSO 1: Analisi dispensa ───────────────────────────────────── // ── AGENTE PASSO 1: Analisi dispensa ─────────────────────────────────────
$send('status', ['step' => 1, 'message' => '📦 Analizzo la dispensa...']); $send('status', ['step' => 1, 'message' => recipeText($lang, 'status_analyze_pantry')]);
$stmt = $db->query(" $stmt = $db->query("
SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at, SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at,
@@ -2849,7 +2947,7 @@ function generateRecipeStream(PDO $db): void {
"); ");
$items = $stmt->fetchAll(PDO::FETCH_ASSOC); $items = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($items)) { $send('error', ['error' => 'La dispensa è vuota!']); return; } if (empty($items)) { $send('error', ['error' => recipeText($lang, 'error_pantry_empty')]); return; }
$getItemPriority = function($item): int { $getItemPriority = function($item): int {
$daysLeft = floatval($item['days_left']); $daysLeft = floatval($item['days_left']);
@@ -2917,13 +3015,13 @@ function generateRecipeStream(PDO $db): void {
$urgentNames = array_slice(array_map( $urgentNames = array_slice(array_map(
fn($l) => trim(preg_replace('/\s[\[\x{26A0}\x{1F534}\x{1F7E0}].*/u', '', explode(':', ltrim($l, '- '))[0])), fn($l) => trim(preg_replace('/\s[\[\x{26A0}\x{1F534}\x{1F7E0}].*/u', '', explode(':', ltrim($l, '- '))[0])),
$urgentRaw), 0, 3); $urgentRaw), 0, 3);
$send('status', ['step' => 1, 'message' => "⚠️ {$urgentCount} urgenti: " . implode(', ', $urgentNames)]); $send('status', ['step' => 1, 'message' => recipeText($lang, 'status_urgent', ['n' => $urgentCount, 'items' => implode(', ', $urgentNames)])]);
} else { } else {
$countMsg = count($items) . ' prodotti trovati'; $countMsg = recipeText($lang, 'status_products_found', ['n' => count($items)]);
if ($hasMealPlan && $totalIngredientsSent < count($items)) { if ($hasMealPlan && $totalIngredientsSent < count($items)) {
$countMsg .= " ({$totalIngredientsSent} passati all'AI)"; $countMsg .= recipeText($lang, 'status_passed_ai', ['n' => $totalIngredientsSent]);
} elseif ($hasMealPlan) { } elseif ($hasMealPlan) {
$countMsg .= ' — tutti passati all\'AI'; $countMsg .= recipeText($lang, 'status_all_passed_ai');
} }
$send('status', ['step' => 1, 'message' => '✅ ' . $countMsg]); $send('status', ['step' => 1, 'message' => '✅ ' . $countMsg]);
} }
@@ -3041,7 +3139,7 @@ function generateRecipeStream(PDO $db): void {
// ── AGENTE PASSO 2: Selezione concetto (locale, nessuna chiamata AI) ──────── // ── AGENTE PASSO 2: Selezione concetto (locale, nessuna chiamata AI) ────────
// Determina il concetto della ricetta in base agli ingredienti disponibili // Determina il concetto della ricetta in base agli ingredienti disponibili
// e ai parametri selezionati — senza consumare quote Gemini. // e ai parametri selezionati — senza consumare quote Gemini.
$send('status', ['step' => 2, 'message' => "🧠 Valuto gli ingredienti disponibili..."]); $send('status', ['step' => 2, 'message' => recipeText($lang, 'status_evaluate_ingredients')]);
// Raccoglie i nomi degli ingredienti di maggiore priorità // Raccoglie i nomi degli ingredienti di maggiore priorità
$conceptIngredients = []; $conceptIngredients = [];
@@ -3057,11 +3155,11 @@ function generateRecipeStream(PDO $db): void {
} }
// Costruisce un messaggio di stato informativo basato su ciò che verrà cucinato // Costruisce un messaggio di stato informativo basato su ciò che verrà cucinato
$conceptMsg = '👨‍🍳 Preparo la ricetta...'; $conceptMsg = recipeText($lang, 'status_preparing_recipe');
if (!empty($mealPlanType) && isset($mealPlanTypeLabels[$mealPlanType]) && $mealPlanTypeLabels[$mealPlanType] !== '') { if (!empty($mealPlanType) && isset($mealPlanTypeLabels[$mealPlanType]) && $mealPlanTypeLabels[$mealPlanType] !== '') {
// Tipo di pasto dal piano settimanale — mostra la categoria // Tipo di pasto dal piano settimanale — mostra la categoria
$shortLabel = explode(' (', $mealPlanTypeLabels[$mealPlanType])[0]; $shortLabel = explode(' (', $mealPlanTypeLabels[$mealPlanType])[0];
$conceptMsg = "🎯 Piatto a base di {$shortLabel}"; $conceptMsg = recipeText($lang, 'status_dish_based_on', ['type' => $shortLabel]);
// Aggiungi l'ingrediente principale se disponibile // Aggiungi l'ingrediente principale se disponibile
if (!empty($matchingItems)) { if (!empty($matchingItems)) {
$firstMatch = ltrim(reset($matchingItems), '→ '); $firstMatch = ltrim(reset($matchingItems), '→ ');
@@ -3071,8 +3169,10 @@ function generateRecipeStream(PDO $db): void {
} elseif (!empty($conceptIngredients)) { } elseif (!empty($conceptIngredients)) {
// Mostra i primi 2 ingredienti più urgenti // Mostra i primi 2 ingredienti più urgenti
$shown = array_slice($conceptIngredients, 0, 2); $shown = array_slice($conceptIngredients, 0, 2);
$conceptMsg = "🥘 Ricetta con " . implode(' e ', array_map('mb_strtolower', $shown)); $a = mb_strtolower($shown[0] ?? '');
if ($variation > 0) $conceptMsg .= " — variante #{$variation}"; $b = mb_strtolower($shown[1] ?? '');
$conceptMsg = recipeText($lang, 'status_recipe_with', ['a' => $a, 'b' => $b]);
if ($variation > 0) $conceptMsg .= recipeText($lang, 'status_variant', ['n' => $variation]);
} elseif (!empty($subType) && !empty($subTypeLabels[$mealType][$subType])) { } elseif (!empty($subType) && !empty($subTypeLabels[$mealType][$subType])) {
$conceptMsg = "🎨 " . explode(' (', $subTypeLabels[$mealType][$subType])[0]; $conceptMsg = "🎨 " . explode(' (', $subTypeLabels[$mealType][$subType])[0];
} }
@@ -3080,10 +3180,12 @@ function generateRecipeStream(PDO $db): void {
// ── AGENTE PASSO 3: Generazione ricetta (A+C: retry SSE-aware + fallback modello) ── // ── AGENTE PASSO 3: Generazione ricetta (A+C: retry SSE-aware + fallback modello) ──
$conceptHint = ''; $conceptHint = '';
$send('status', ['step' => 3, 'message' => '✍️ Creo la ricetta completa...']); $send('status', ['step' => 3, 'message' => recipeText($lang, 'status_creating_full_recipe')]);
$promptLanguageRule = recipeText($lang, 'prompt_lang_rule');
$promptStepExample = recipeText($lang, 'prompt_step_example');
$prompt = <<<PROMPT $prompt = <<<PROMPT
Sei uno chef italiano esperto. Genera UNA ricetta per $mealLabel per $persons persona/e usando gli ingredienti disponibili sotto.{$extraRulesText}{$appliancesText}{$dietaryText}{$subTypeText}{$mealPlanText}{$varietyText}{$regenText}{$mustUseText} You are an expert home chef. Generate ONE recipe for $mealLabel for $persons person(s) using the available ingredients below.{$extraRulesText}{$appliancesText}{$dietaryText}{$subTypeText}{$mealPlanText}{$varietyText}{$regenText}{$mustUseText}
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.
@@ -3092,12 +3194,14 @@ REGOLE:
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).
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged.
DISPENSA: DISPENSA:
$ingredientsText $ingredientsText
Rispondi SOLO JSON valido (no markdown): Rispondi SOLO JSON valido (no markdown):
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["Passo 1…"],"nutrition_note":"…"} {$promptLanguageRule}
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
PROMPT; PROMPT;
$genConfig = [ $genConfig = [
@@ -3171,15 +3275,15 @@ PROMPT;
// A: feedback live con countdown // A: feedback live con countdown
$modelName = str_replace('gemini-', 'Gemini ', $model); $modelName = str_replace('gemini-', 'Gemini ', $model);
$send('status', ['step' => 3, 'message' => "⏳ Quota TPM esaurita ({$modelName}), attendo {$waitSec}s... (tentativo {$attempt}/{$maxRetries})"]); $send('status', ['step' => 3, 'message' => recipeText($lang, 'status_quota_wait', ['model' => $modelName, 's' => $waitSec, 'a' => $attempt, 'm' => $maxRetries])]);
sleep($waitSec); sleep($waitSec);
$send('status', ['step' => 3, 'message' => '✍️ Riprovo la generazione...']); $send('status', ['step' => 3, 'message' => recipeText($lang, 'status_retry_generation')]);
} }
// C: se primario esaurito dopo tutti i retry, cambia modello immediatamente // C: se primario esaurito dopo tutti i retry, cambia modello immediatamente
if ($httpCode === 429 && $modelIdx === 0) { if ($httpCode === 429 && $modelIdx === 0) {
$fallbackName = str_replace('gemini-', 'Gemini ', $models[1]); $fallbackName = str_replace('gemini-', 'Gemini ', $models[1]);
$send('status', ['step' => 3, 'message' => "🔄 Cambio modello → {$fallbackName}..."]); $send('status', ['step' => 3, 'message' => recipeText($lang, 'status_switch_model', ['model' => $fallbackName])]);
continue; continue;
} }
break; break;
@@ -3187,7 +3291,7 @@ PROMPT;
if ($httpCode !== 200) { if ($httpCode !== 200) {
$errDetail = $result['data']['error']['message'] ?? substr($result['body'], 0, 300); $errDetail = $result['data']['error']['message'] ?? substr($result['body'], 0, 300);
$send('error', ['error' => 'Errore API Gemini', 'http_code' => $httpCode, 'detail' => $errDetail]); $send('error', ['error' => recipeText($lang, 'error_gemini_api'), 'http_code' => $httpCode, 'detail' => $errDetail]);
return; return;
} }
@@ -3198,7 +3302,7 @@ PROMPT;
$recipe = json_decode($text, true); $recipe = json_decode($text, true);
if (!$recipe || empty($recipe['title'])) { if (!$recipe || empty($recipe['title'])) {
$send('error', ['error' => 'Impossibile generare la ricetta', 'raw' => $text]); $send('error', ['error' => recipeText($lang, 'error_cannot_generate'), 'raw' => $text]);
return; return;
} }
+210 -164
View File
@@ -1634,8 +1634,8 @@ async function loadSettingsUI() {
// Render legend // Render legend
const legend = document.querySelector('.mplan-legend'); const legend = document.querySelector('.mplan-legend');
if (legend) { if (legend) {
legend.innerHTML = MEAL_PLAN_TYPES.map(t => legend.innerHTML = getMealPlanTypes().map(mpt =>
`<span class="mplan-badge" style="opacity:0.85">${t.icon} ${t.label}</span>` `<span class="mplan-badge" style="opacity:0.85">${mpt.icon} ${mpt.label}</span>`
).join(''); ).join('');
} }
// TTS settings — init defaults on first load // TTS settings — init defaults on first load
@@ -3866,7 +3866,7 @@ async function onBarcodeDetected(barcode) {
// Save to local DB // Save to local DB
const saveResult = await api('product_save', {}, 'POST', { const saveResult = await api('product_save', {}, 'POST', {
barcode: barcode, barcode: barcode,
name: p.name || 'Prodotto sconosciuto', name: p.name || t('product.not_recognized'),
brand: p.brand || '', brand: p.brand || '',
category: p.category || '', category: p.category || '',
image_url: p.image_url || '', image_url: p.image_url || '',
@@ -3880,7 +3880,7 @@ async function onBarcodeDetected(barcode) {
currentProduct = { currentProduct = {
id: saveResult.id, id: saveResult.id,
barcode: barcode, barcode: barcode,
name: p.name || 'Prodotto sconosciuto', name: p.name || t('product.not_recognized'),
brand: p.brand || '', brand: p.brand || '',
category: p.category || '', category: p.category || '',
image_url: p.image_url || '', image_url: p.image_url || '',
@@ -3909,13 +3909,13 @@ async function onBarcodeDetected(barcode) {
// Not found - ask user to add manually // Not found - ask user to add manually
showLoading(false); showLoading(false);
stopScanner(); stopScanner();
showToast('Prodotto non trovato. Inseriscilo manualmente.', 'error'); showToast(t('error.not_found_manual'), 'error');
startManualEntry(barcode); startManualEntry(barcode);
} catch (err) { } catch (err) {
showLoading(false); showLoading(false);
console.error('Barcode lookup error:', err); console.error('Barcode lookup error:', err);
showToast('Errore nella ricerca. Riprova.', 'error'); showToast(t('error.search'), 'error');
} }
} }
@@ -3928,7 +3928,7 @@ function submitManualBarcode() {
return; return;
} }
if (!/^\d{4,14}$/.test(barcode)) { if (!/^\d{4,14}$/.test(barcode)) {
showToast('Il codice a barre deve contenere solo numeri (4-14 cifre)', 'error'); showToast(t('error.barcode_format'), 'error');
input.focus(); input.focus();
return; return;
} }
@@ -4002,8 +4002,8 @@ function showQuickNameResults(searchName, products) {
newItem.innerHTML = ` newItem.innerHTML = `
<span class="qnr-icon"></span> <span class="qnr-icon"></span>
<div class="qnr-info"> <div class="qnr-info">
<div class="qnr-name">Crea "${escapeHtml(searchName)}"</div> <div class="qnr-name">${t('scan.create_named').replace('{name}', '"' + escapeHtml(searchName) + '"')}</div>
<div class="qnr-detail">Nuovo prodotto senza barcode</div> <div class="qnr-detail">${t('scan.new_without_barcode')}</div>
</div> </div>
`; `;
newItem.onclick = () => createQuickProduct(searchName); newItem.onclick = () => createQuickProduct(searchName);
@@ -4616,12 +4616,12 @@ function showProductAction() {
banner.style.display = 'block'; banner.style.display = 'block';
banner.innerHTML = ` banner.innerHTML = `
<div class="shopping-scan-target-info"> <div class="shopping-scan-target-info">
<span class="stb-label">🛒 Stai cercando</span> <span class="stb-label">🛒 ${t('shopping.scan_target_label')}</span>
<span class="stb-name">${escapeHtml(targetName)}</span> <span class="stb-name">${escapeHtml(targetName)}</span>
</div> </div>
<div class="shopping-scan-target-actions"> <div class="shopping-scan-target-actions">
<button class="btn btn-success stb-btn" onclick="confirmShoppingItemFound()"> Trovato! Rimuovi dalla lista</button> <button class="btn btn-success stb-btn" onclick="confirmShoppingItemFound()"> ${t('shopping.scan_target_found')}</button>
<button class="btn btn-secondary stb-btn" onclick="_spesaScanTarget=null; document.getElementById('shopping-scan-target-banner').style.display='none'; document.getElementById('action-back-btn').onclick=()=>showPage('scan')"> Annulla</button> <button class="btn btn-secondary stb-btn" onclick="_spesaScanTarget=null; document.getElementById('shopping-scan-target-banner').style.display='none'; document.getElementById('action-back-btn').onclick=()=>showPage('scan')"> ${t('btn.cancel')}</button>
</div> </div>
`; `;
} else if (banner) { } else if (banner) {
@@ -5629,7 +5629,7 @@ async function loadUseInventoryInfo() {
const unitSwitch = document.getElementById('use-unit-switch'); const unitSwitch = document.getElementById('use-unit-switch');
if (items.length === 0) { if (items.length === 0) {
infoEl.innerHTML = '⚠️ Prodotto non presente nell\'inventario.'; infoEl.innerHTML = t('use.not_in_inventory');
unitSwitch.style.display = 'none'; unitSwitch.style.display = 'none';
_useConfMode = null; _useConfMode = null;
document.getElementById('use-expiry-hint').style.display = 'none'; document.getElementById('use-expiry-hint').style.display = 'none';
@@ -5675,7 +5675,7 @@ async function loadUseInventoryInfo() {
locSelector.innerHTML = ` locSelector.innerHTML = `
<div class="pref-loc-info" id="pref-loc-info"> <div class="pref-loc-info" id="pref-loc-info">
<span class="pref-loc-name">${locInfo.icon} ${locInfo.label}</span> <span class="pref-loc-name">${locInfo.icon} ${locInfo.label}</span>
<button type="button" class="btn-link pref-loc-change" onclick="_expandUseLocationSelector()">cambia</button> <button type="button" class="btn-link pref-loc-change" onclick="_expandUseLocationSelector()">${t('use.change')}</button>
</div> </div>
<div id="pref-loc-full" style="display:none">${buildLocButtons(activeLoc)}</div> <div id="pref-loc-full" style="display:none">${buildLocButtons(activeLoc)}</div>
`; `;
@@ -5699,7 +5699,7 @@ async function loadUseInventoryInfo() {
_useConfMode = { packageSize: pkgSize, packageUnit: pkgUnit, totalSub, totalConf, subLabel }; _useConfMode = { packageSize: pkgSize, packageUnit: pkgUnit, totalSub, totalConf, subLabel };
// Show inventory info with sub-unit total // Show inventory info with sub-unit total
infoEl.innerHTML = '<strong>📦 Disponibile:</strong> ' + items.map(i => { infoEl.innerHTML = `<strong>${t('use.available')}</strong> ` + items.map(i => {
const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location }; const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location };
const confQty = parseFloat(i.quantity); const confQty = parseFloat(i.quantity);
const subQty = Math.round(confQty * pkgSize); const subQty = Math.round(confQty * pkgSize);
@@ -5723,7 +5723,7 @@ async function loadUseInventoryInfo() {
// Trigger a live-box refresh with the latest reading if on scale // Trigger a live-box refresh with the latest reading if on scale
if (_scaleLatestWeight) _scaleAutoFillUse(_scaleLatestWeight); if (_scaleLatestWeight) _scaleAutoFillUse(_scaleLatestWeight);
infoEl.innerHTML = '<strong>📦 Disponibile:</strong> ' + items.map(i => { infoEl.innerHTML = `<strong>${t('use.available')}</strong> ` + items.map(i => {
const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location }; const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location };
const qLabel = formatQuantity(parseFloat(i.quantity), i.unit, i.default_quantity, i.package_unit); const qLabel = formatQuantity(parseFloat(i.quantity), i.unit, i.default_quantity, i.package_unit);
return `${loc.icon} ${loc.label}: ${qLabel}`; return `${loc.icon} ${loc.label}: ${qLabel}`;
@@ -5743,12 +5743,12 @@ async function loadUseInventoryInfo() {
fracDiv.id = 'pz-fraction-btns'; fracDiv.id = 'pz-fraction-btns';
fracDiv.className = 'pz-fraction-btns'; fracDiv.className = 'pz-fraction-btns';
fracDiv.innerHTML = ` fracDiv.innerHTML = `
<p class="form-hint">Hai usato solo una parte?</p> <p class="form-hint">${t('use.partial_piece_hint')}</p>
<div class="fraction-btn-row"> <div class="fraction-btn-row">
<button type="button" class="frac-btn" data-frac="0.25" onclick="setPzFraction(0.25)">¼ pezzo</button> <button type="button" class="frac-btn" data-frac="0.25" onclick="setPzFraction(0.25)">¼ ${t('use.piece')}</button>
<button type="button" class="frac-btn" data-frac="0.5" onclick="setPzFraction(0.5)">½ pezzo</button> <button type="button" class="frac-btn" data-frac="0.5" onclick="setPzFraction(0.5)">½ ${t('use.piece')}</button>
<button type="button" class="frac-btn" data-frac="0.75" onclick="setPzFraction(0.75)">¾ pezzo</button> <button type="button" class="frac-btn" data-frac="0.75" onclick="setPzFraction(0.75)">¾ ${t('use.piece')}</button>
<button type="button" class="frac-btn active" data-frac="1" onclick="setPzFraction(1)">1 intero</button> <button type="button" class="frac-btn active" data-frac="1" onclick="setPzFraction(1)">${t('use.one_whole')}</button>
</div>`; </div>`;
document.querySelector('#page-use .use-partial').appendChild(fracDiv); document.querySelector('#page-use .use-partial').appendChild(fracDiv);
} }
@@ -5772,7 +5772,7 @@ function switchUseUnit(mode) {
qtyInput.value = step; qtyInput.value = step;
qtyInput.step = step; qtyInput.step = step;
qtyInput.min = step; qtyInput.min = step;
hint.textContent = `Quantità in ${_useConfMode.subLabel} (totale: ${Math.round(_useConfMode.totalSub)}${_useConfMode.subLabel})`; hint.textContent = t('recipes.quantity_in_total', { unit: _useConfMode.subLabel, total: `${Math.round(_useConfMode.totalSub)}${_useConfMode.subLabel}` });
} else { } else {
confBtn.classList.add('active'); confBtn.classList.add('active');
subBtn.classList.remove('active'); subBtn.classList.remove('active');
@@ -5780,7 +5780,7 @@ function switchUseUnit(mode) {
qtyInput.value = 1; qtyInput.value = 1;
qtyInput.step = 0.5; qtyInput.step = 0.5;
qtyInput.min = 0.5; qtyInput.min = 0.5;
hint.textContent = `Confezioni da ${_useConfMode.packageSize}${_useConfMode.subLabel} (hai ${_useConfMode.totalConf.toFixed(1)} conf)`; hint.textContent = t('recipes.packs_of_have', { size: `${_useConfMode.packageSize}${_useConfMode.subLabel}`, count: _useConfMode.totalConf.toFixed(1) });
} }
} }
@@ -6062,12 +6062,12 @@ async function addLowStockToBring() {
if (shoppingListUUID) payload.listUUID = shoppingListUUID; if (shoppingListUUID) payload.listUUID = shoppingListUUID;
const data = await api('bring_add', {}, 'POST', payload); const data = await api('bring_add', {}, 'POST', payload);
if (data.success && data.added > 0) { if (data.success && data.added > 0) {
showToast('🛒 Aggiunto alla lista della spesa!', 'success'); showToast(t('shopping.added_to_bring').replace('{n}', data.added), 'success');
} else if (data.success && data.skipped > 0) { } else if (data.success && data.skipped > 0) {
showToast(t('shopping.already_in_list_short'), 'info'); showToast(t('shopping.already_in_list_short'), 'info');
} }
} catch (e) { } catch (e) {
showToast('Errore nell\'aggiunta a Bring!', 'error'); showToast(t('error.bring_add'), 'error');
} }
const cb = window._lowStockAfterCallback; const cb = window._lowStockAfterCallback;
window._lowStockAfterCallback = null; window._lowStockAfterCallback = null;
@@ -6448,7 +6448,7 @@ async function selectLocalMatch(productId) {
showProductAction(); showProductAction();
} else { } else {
showLoading(false); showLoading(false);
showToast('Prodotto non trovato', 'error'); showToast(t('error.not_found'), 'error');
} }
} catch (err) { } catch (err) {
showLoading(false); showLoading(false);
@@ -6575,7 +6575,7 @@ let _pfAiStream = null;
async function captureForAIFormFill() { async function captureForAIFormFill() {
document.getElementById('modal-content').innerHTML = ` document.getElementById('modal-content').innerHTML = `
<div class="modal-header"> <div class="modal-header">
<h3>📷 Identifica con AI</h3> <h3>📷 ${t('scan.ai_identify')}</h3>
<button class="modal-close" onclick="closePfAiScanner()"></button> <button class="modal-close" onclick="closePfAiScanner()"></button>
</div> </div>
<div class="expiry-scanner"> <div class="expiry-scanner">
@@ -6710,7 +6710,7 @@ async function _pfAiAnalyze(base64) {
} catch (err) { } catch (err) {
statusEl.style.display = 'none'; statusEl.style.display = 'none';
resultEl.style.display = 'block'; resultEl.style.display = 'block';
resultEl.innerHTML = `<p style="color:var(--danger);text-align:center">❌ Errore di connessione</p> resultEl.innerHTML = `<p style="color:var(--danger);text-align:center">❌ ${t('error.connection')}</p>
<button class="btn btn-secondary full-width" onclick="pfAiRetake()">${t('btn.retry')}</button>`; <button class="btn btn-secondary full-width" onclick="pfAiRetake()">${t('btn.retry')}</button>`;
} }
} }
@@ -7017,7 +7017,7 @@ async function autoAddCriticalItems() {
*/ */
async function forceSyncBring() { async function forceSyncBring() {
const btn = document.getElementById('btn-force-sync'); const btn = document.getElementById('btn-force-sync');
if (btn) { btn.disabled = true; btn.textContent = '⏳ Sincronizzazione…'; } if (btn) { btn.disabled = true; btn.textContent = `${t('shopping.syncing')}`; }
// Clear all guards so the next run is unconditional // Clear all guards so the next run is unconditional
localStorage.removeItem('_bringPurchasedBlocklist'); localStorage.removeItem('_bringPurchasedBlocklist');
localStorage.removeItem('_autoAddedCriticalTs'); localStorage.removeItem('_autoAddedCriticalTs');
@@ -7025,8 +7025,8 @@ async function forceSyncBring() {
logOperation('force_sync_bring', {}); logOperation('force_sync_bring', {});
// Reload everything from scratch // Reload everything from scratch
await loadShoppingList(); await loadShoppingList();
if (btn) { btn.disabled = false; btn.textContent = '🔄 Forza sincronizzazione Bring!'; } if (btn) { btn.disabled = false; btn.textContent = `🔄 ${t('shopping.force_sync')}`; }
showToast('🔄 Sincronizzazione completata', 'success'); showToast(`🔄 ${t('shopping.sync_done')}`, 'success');
} }
/** /**
@@ -7627,7 +7627,7 @@ async function loadShoppingList() {
const suggestionsEl = document.getElementById('shopping-suggestions'); const suggestionsEl = document.getElementById('shopping-suggestions');
statusEl.style.display = 'block'; statusEl.style.display = 'block';
statusEl.innerHTML = '<div class="bring-loading"><div class="loading-spinner"></div> Connessione a Bring!...</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';
suggestionsEl.style.display = 'none'; suggestionsEl.style.display = 'none';
@@ -7637,7 +7637,7 @@ async function loadShoppingList() {
if (!data.success) { if (!data.success) {
statusEl.style.display = 'block'; statusEl.style.display = 'block';
statusEl.innerHTML = `<div class="bring-error">⚠️ ${escapeHtml(data.error || 'Errore connessione Bring!')}</div>`; statusEl.innerHTML = `<div class="bring-error">⚠️ ${escapeHtml(data.error || t('error.bring_connection'))}</div>`;
return; return;
} }
@@ -7716,7 +7716,7 @@ async function renderShoppingItems() {
if (tabCount) tabCount.textContent = shoppingItems.length; if (tabCount) tabCount.textContent = shoppingItems.length;
if (shoppingItems.length === 0) { if (shoppingItems.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:20px"><div class="empty-state-icon">✅</div><p>Lista della spesa vuota!<br>Usa il pulsante sotto per generare suggerimenti.</p></div>'; container.innerHTML = `<div class="empty-state" style="padding:20px"><div class="empty-state-icon">✅</div><p>${t('shopping.empty')}</p></div>`;
updateSpesaTotal(); updateSpesaTotal();
return; return;
} }
@@ -7823,7 +7823,7 @@ async function renderShoppingItems() {
let spesaBar = ''; let spesaBar = '';
if (hasSpesa) { if (hasSpesa) {
if (priceData && priceData.loading) { if (priceData && priceData.loading) {
detailHtml = `<div class="spesa-loading">🔍 Cerco...</div>`; detailHtml = `<div class="spesa-loading">🔍 ${t('shopping.price_searching')}</div>`;
} else if (priceData && priceData.product) { } else if (priceData && priceData.product) {
const p = priceData.product; const p = priceData.product;
const promoHtml = p.promo const promoHtml = p.promo
@@ -7839,23 +7839,23 @@ async function renderShoppingItems() {
${promoHtml} ${promoHtml}
</div>`; </div>`;
spesaBar = `<div class="spesa-bar"> spesaBar = `<div class="spesa-bar">
<button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx}, true)" title="Ricerca">🔄 Ricerca</button> <button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx}, true)" title="${t('shopping.search_action')}">🔄 ${t('shopping.search_action')}</button>
<a href="${escapeHtml(p.url)}" target="_blank" class="spesa-bar-btn" title="${escapeHtml(p.name)}" onclick="event.stopPropagation()">🔗 Apri</a> <a href="${escapeHtml(p.url)}" target="_blank" class="spesa-bar-btn" title="${escapeHtml(p.name)}" onclick="event.stopPropagation()">🔗 ${t('shopping.open_action')}</a>
</div>`; </div>`;
} else if (priceData && priceData.searched && !priceData.product) { } else if (priceData && priceData.searched && !priceData.product) {
detailHtml = `<div class="spesa-detail-left"><span class="spesa-not-found">Non trovato</span></div>`; detailHtml = `<div class="spesa-detail-left"><span class="spesa-not-found">${t('shopping.not_found')}</span></div>`;
spesaBar = `<div class="spesa-bar"> spesaBar = `<div class="spesa-bar">
<button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx}, true)" title="${t('btn.retry').replace('🔄 ', '')}">${t('btn.retry')}</button> <button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx}, true)" title="${t('btn.retry').replace('🔄 ', '')}">${t('btn.retry')}</button>
</div>`; </div>`;
} else { } else {
spesaBar = `<div class="spesa-bar"> spesaBar = `<div class="spesa-bar">
<button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx})" title="Cerca prezzo">🔍 Cerca prezzo</button> <button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx})" title="${t('shopping.search_price')}">🔍 ${t('shopping.search_price')}</button>
</div>`; </div>`;
} }
} }
html += ` html += `
<div class="shopping-item ${priceData?.product?.promo ? 'has-promo' : ''}" id="shop-item-${idx}" onclick="openScanForItem(${idx})" title="Tocca per scansionare"${bgStyle}> <div class="shopping-item ${priceData?.product?.promo ? 'has-promo' : ''}" id="shop-item-${idx}" onclick="openScanForItem(${idx})" title="${t('shopping.tap_to_scan')}"${bgStyle}>
<span class="shopping-item-icon">${catIcon}</span> <span class="shopping-item-icon">${catIcon}</span>
<div class="shopping-item-body"> <div class="shopping-item-body">
<div class="shopping-item-top"> <div class="shopping-item-top">
@@ -7870,8 +7870,8 @@ async function renderShoppingItems() {
</div> </div>
<div class="shopping-item-right" onclick="event.stopPropagation()"> <div class="shopping-item-right" onclick="event.stopPropagation()">
${priceTag} ${priceTag}
<button class="shopping-item-tag-btn" onclick="toggleShoppingTagMenu(this)" title="Tag">🏷</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="Rimuovi"></button> <button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="${t('shopping.remove_title')}"></button>
</div> </div>
</div> </div>
${spesaBar} ${spesaBar}
@@ -7923,9 +7923,9 @@ function updateSpesaTotal() {
banner.style.display = 'block'; banner.style.display = 'block';
valueEl.textContent = `${total.toFixed(2)}`; valueEl.textContent = `${total.toFixed(2)}`;
let detail = `${found}/${shoppingItems.length} prodotti trovati`; let detail = t('shopping.found_count').replace('{found}', found).replace('{total}', shoppingItems.length);
if (promoSaved > 0) { if (promoSaved > 0) {
detail += ` · 🏷️ Risparmi €${promoSaved.toFixed(2)} con le offerte`; detail += ` ${t('shopping.savings_offers').replace('{amount}', promoSaved.toFixed(2))}`;
} }
detailEl.textContent = detail; detailEl.textContent = detail;
} }
@@ -7982,14 +7982,14 @@ async function searchAllPrices() {
try { try {
const status = await api('dupliclick_status'); const status = await api('dupliclick_status');
if (!status.logged_in) { if (!status.logged_in) {
showToast('Configura prima la Spesa Online nelle impostazioni', 'error'); showToast(t('settings.spesa.configure_first'), 'error');
return; return;
} }
s.spesa_logged_in = true; s.spesa_logged_in = true;
s.spesa_token = 'server'; s.spesa_token = 'server';
saveSettings(s); saveSettings(s);
} catch (e) { } catch (e) {
showToast('Configura prima la Spesa Online nelle impostazioni', 'error'); showToast(t('settings.spesa.configure_first'), 'error');
return; return;
} }
} }
@@ -8001,7 +8001,7 @@ async function searchAllPrices() {
}); });
if (toSearch.length === 0) { if (toSearch.length === 0) {
showToast('Tutti i prodotti sono già stati cercati. Usa 🔄 per ricercare singoli.', 'info'); showToast(t('shopping.all_searched'), 'info');
return; return;
} }
@@ -8010,7 +8010,7 @@ async function searchAllPrices() {
for (let i = 0; i < toSearch.length; i++) { for (let i = 0; i < toSearch.length; i++) {
const item = toSearch[i]; const item = toSearch[i];
btn.innerHTML = `Cerco ${i + 1}/${totalToSearch}...`; btn.innerHTML = `${t('shopping.searching_progress').replace('{current}', i + 1).replace('{total}', totalToSearch)}`;
const priceKey = item.name.toLowerCase(); const priceKey = item.name.toLowerCase();
const provider = s.spesa_provider || 'dupliclick'; const provider = s.spesa_provider || 'dupliclick';
@@ -8041,8 +8041,8 @@ async function searchAllPrices() {
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '🔍 Cerca tutti i prezzi'; btn.innerHTML = `🔍 ${t('shopping.search_prices')}`;
showToast(`Ricerca completata: ${totalToSearch} prodotti`, 'success'); showToast(t('shopping.search_complete').replace('{count}', totalToSearch), 'success');
} }
async function removeBringItem(idx) { async function removeBringItem(idx) {
@@ -8063,7 +8063,7 @@ async function removeBringItem(idx) {
loadShoppingCount(); loadShoppingCount();
} }
} catch (err) { } catch (err) {
showToast('Errore nella rimozione', 'error'); showToast(t('shopping.remove_error'), 'error');
} }
} }
@@ -8072,17 +8072,17 @@ async function generateSuggestions() {
const suggestionsEl = document.getElementById('shopping-suggestions'); const suggestionsEl = document.getElementById('shopping-suggestions');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<div class="loading-spinner" style="display:inline-block;width:18px;height:18px;margin-right:8px;vertical-align:middle"></div> Analisi in corso...'; btn.innerHTML = `<div class="loading-spinner" style="display:inline-block;width:18px;height:18px;margin-right:8px;vertical-align:middle"></div> ${t('shopping.suggest_loading')}`;
suggestionsEl.style.display = 'none'; suggestionsEl.style.display = 'none';
try { try {
const data = await api('bring_suggest', {}, 'POST', {}); const data = await api('bring_suggest', {}, 'POST', {});
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '🤖 Suggerisci cosa comprare'; btn.innerHTML = `🤖 ${t('shopping.suggest_btn').replace('🤖 ', '')}`;
if (!data.success) { if (!data.success) {
showToast(data.error || 'Errore nella generazione', 'error'); showToast(data.error || t('shopping.suggest_error'), 'error');
return; return;
} }
@@ -8106,7 +8106,7 @@ async function generateSuggestions() {
} catch (err) { } catch (err) {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '🤖 Suggerisci cosa comprare'; btn.innerHTML = `🤖 ${t('shopping.suggest_btn').replace('🤖 ', '')}`;
console.error('Suggestion error:', err); console.error('Suggestion error:', err);
showToast(t('error.connection'), 'error'); showToast(t('error.connection'), 'error');
} }
@@ -8121,9 +8121,9 @@ function renderSuggestions() {
container.innerHTML = sorted.map((item, idx) => { container.innerHTML = sorted.map((item, idx) => {
const catIcon = CATEGORY_ICONS[item.category] || '🛒'; const catIcon = CATEGORY_ICONS[item.category] || '🛒';
const priorityBadge = { const priorityBadge = {
'alta': '<span class="priority-badge priority-high">Alta</span>', 'alta': `<span class="priority-badge priority-high">${t('shopping.priority_high')}</span>`,
'media': '<span class="priority-badge priority-med">Media</span>', 'media': `<span class="priority-badge priority-med">${t('shopping.priority_medium')}</span>`,
'bassa': '<span class="priority-badge priority-low">Bassa</span>', 'bassa': `<span class="priority-badge priority-low">${t('shopping.priority_low')}</span>`,
}[item.priority] || ''; }[item.priority] || '';
return ` return `
@@ -8157,8 +8157,7 @@ function updateSuggestionActionBtn() {
const btn = document.querySelector('#suggestion-actions .btn-success'); const btn = document.querySelector('#suggestion-actions .btn-success');
if (btn) { if (btn) {
const nItems = selected.length; const nItems = selected.length;
const prodStr = nItems === 1 ? 'prodotto' : 'prodotti'; btn.textContent = `${nItems === 1 ? t('shopping.bring_add_one') : t('shopping.bring_add_many').replace('{n}', nItems)}`;
btn.textContent = `${t('shopping.bring_add_n').replace('{n}', nItems + ' ' + prodStr)}!`;
btn.disabled = nItems === 0; btn.disabled = nItems === 0;
} }
} }
@@ -8193,7 +8192,7 @@ async function addSelectedSuggestions() {
document.getElementById('shopping-suggestions').style.display = 'none'; document.getElementById('shopping-suggestions').style.display = 'none';
suggestionItems = []; suggestionItems = [];
} else { } else {
showToast(data.error || 'Errore', 'error'); showToast(data.error || t('error.generic'), 'error');
} }
} catch (err) { } catch (err) {
showToast(t('error.connection'), 'error'); showToast(t('error.connection'), 'error');
@@ -8516,29 +8515,45 @@ async function undoTransactionEntry(id, type, name) {
* 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.
*/ */
const MEAL_PLAN_TYPES = [ const MEAL_PLAN_TYPE_DEFS = [
{ id: 'pasta', icon: '🍝', label: t('meal_plan_types.pasta') }, { id: 'pasta', icon: '🍝', i18nKey: 'meal_plan_types.pasta' },
{ id: 'riso', icon: '🍚', label: t('meal_plan_types.riso') }, { id: 'riso', icon: '🍚', i18nKey: 'meal_plan_types.riso' },
{ id: 'carne', icon: '🥩', label: t('meal_plan_types.carne') }, { id: 'carne', icon: '🥩', i18nKey: 'meal_plan_types.carne' },
{ id: 'pesce', icon: '🐟', label: t('meal_plan_types.pesce') }, { id: 'pesce', icon: '🐟', i18nKey: 'meal_plan_types.pesce' },
{ id: 'legumi', icon: '🫘', label: t('meal_plan_types.legumi') }, { id: 'legumi', icon: '🫘', i18nKey: 'meal_plan_types.legumi' },
{ id: 'uova', icon: '🥚', label: t('meal_plan_types.uova') }, { id: 'uova', icon: '🥚', i18nKey: 'meal_plan_types.uova' },
{ id: 'formaggio', icon: '🧀', label: t('meal_plan_types.formaggio') }, { id: 'formaggio', icon: '🧀', i18nKey: 'meal_plan_types.formaggio' },
{ id: 'pizza', icon: '🍕', label: t('meal_plan_types.pizza') }, { id: 'pizza', icon: '🍕', i18nKey: 'meal_plan_types.pizza' },
{ id: 'affettati', icon: '🥓', label: t('meal_plan_types.affettati') }, { id: 'affettati', icon: '🥓', i18nKey: 'meal_plan_types.affettati' },
{ id: 'verdure', icon: '🥦', label: t('meal_plan_types.verdure') }, { id: 'verdure', icon: '🥦', i18nKey: 'meal_plan_types.verdure' },
{ id: 'zuppa', icon: '🍲', label: t('meal_plan_types.zuppa') }, { id: 'zuppa', icon: '🍲', i18nKey: 'meal_plan_types.zuppa' },
{ id: 'insalata', icon: '🥗', label: t('meal_plan_types.insalata') }, { id: 'insalata', icon: '🥗', i18nKey: 'meal_plan_types.insalata' },
{ id: 'pane', icon: '🥪', label: t('meal_plan_types.pane') }, { id: 'pane', icon: '🥪', i18nKey: 'meal_plan_types.pane' },
{ id: 'dolce', icon: '🍰', label: t('meal_plan_types.dolce') }, { id: 'dolce', icon: '🍰', i18nKey: 'meal_plan_types.dolce' },
{ id: 'libero', icon: '🎲', label: t('meal_plan_types.libero') }, { id: 'libero', icon: '🎲', i18nKey: 'meal_plan_types.libero' },
]; ];
const MEAL_PLAN_TYPE_MAP = {}; function getMealPlanTypes() {
MEAL_PLAN_TYPES.forEach(mpt => { MEAL_PLAN_TYPE_MAP[mpt.id] = mpt; }); return MEAL_PLAN_TYPE_DEFS.map(mpt => ({ ...mpt, label: t(mpt.i18nKey) }));
}
const WEEK_DAYS = [t('days.mon'),t('days.tue'),t('days.wed'),t('days.thu'),t('days.fri'),t('days.sat'),t('days.sun')]; function getMealPlanTypeMap() {
const WEEK_DAYS_SHORT = [t('days.mon_short'),t('days.tue_short'),t('days.wed_short'),t('days.thu_short'),t('days.fri_short'),t('days.sat_short'),t('days.sun_short')]; const map = {};
getMealPlanTypes().forEach(mpt => { map[mpt.id] = mpt; });
return map;
}
function getWeekDaysShortLabels() {
return [
t('days.mon_short'),
t('days.tue_short'),
t('days.wed_short'),
t('days.thu_short'),
t('days.fri_short'),
t('days.sat_short'),
t('days.sun_short'),
];
}
/** Default weekly plan as requested. */ /** Default weekly plan as requested. */
const DEFAULT_MEAL_PLAN = { const DEFAULT_MEAL_PLAN = {
@@ -8590,6 +8605,8 @@ function renderMealPlanEditor() {
// JS getDay: 0=Sun … but we display Mon-Sun (1..6,0) // JS getDay: 0=Sun … but we display Mon-Sun (1..6,0)
const dayOrder = [1,2,3,4,5,6,0]; const dayOrder = [1,2,3,4,5,6,0];
const today = new Date().getDay(); const today = new Date().getDay();
const mealPlanTypeMap = getMealPlanTypeMap();
const weekDaysShort = getWeekDaysShortLabels();
const header = `<div class="mplan-header"> const header = `<div class="mplan-header">
<span class="mplan-col-header">🌤 ${t('meal_types.pranzo')}</span> <span class="mplan-col-header">🌤 ${t('meal_types.pranzo')}</span>
@@ -8599,11 +8616,11 @@ function renderMealPlanEditor() {
const rows = dayOrder.map((dow, i) => { const rows = dayOrder.map((dow, i) => {
const pranzo = plan[dow]?.pranzo || 'libero'; const pranzo = plan[dow]?.pranzo || 'libero';
const cena = plan[dow]?.cena || 'libero'; const cena = plan[dow]?.cena || 'libero';
const pt = MEAL_PLAN_TYPE_MAP[pranzo] || MEAL_PLAN_TYPE_MAP.libero; const pt = mealPlanTypeMap[pranzo] || mealPlanTypeMap.libero;
const ct = MEAL_PLAN_TYPE_MAP[cena] || MEAL_PLAN_TYPE_MAP.libero; const ct = mealPlanTypeMap[cena] || mealPlanTypeMap.libero;
const todayClass = dow === today ? ' mplan-row-today' : ''; const todayClass = dow === today ? ' mplan-row-today' : '';
return `<div class="mplan-row${todayClass}"> return `<div class="mplan-row${todayClass}">
<div class="mplan-day-name">${WEEK_DAYS_SHORT[i]}</div> <div class="mplan-day-name">${weekDaysShort[i]}</div>
<span class="mplan-badge mplan-badge-pranzo" onclick="openMealPlanPicker(${dow},'pranzo',this)">${pt.icon} ${pt.label}</span> <span class="mplan-badge mplan-badge-pranzo" onclick="openMealPlanPicker(${dow},'pranzo',this)">${pt.icon} ${pt.label}</span>
<span class="mplan-badge mplan-badge-cena" onclick="openMealPlanPicker(${dow},'cena',this)">${ct.icon} ${ct.label}</span> <span class="mplan-badge mplan-badge-cena" onclick="openMealPlanPicker(${dow},'cena',this)">${ct.icon} ${ct.label}</span>
</div>`; </div>`;
@@ -8621,8 +8638,8 @@ function openMealPlanPicker(dow, slot, badgeEl) {
if (!picker) return; if (!picker) return;
const plan = getMealPlan(); const plan = getMealPlan();
const current = plan[dow]?.[slot] || 'libero'; const current = plan[dow]?.[slot] || 'libero';
picker.innerHTML = MEAL_PLAN_TYPES.map(t => picker.innerHTML = getMealPlanTypes().map(mpt =>
`<button class="mplan-pick-btn${t.id === current ? ' active' : ''}" onclick="selectMealPlanType(${dow},'${slot}','${t.id}')">${t.icon} ${t.label}</button>` `<button class="mplan-pick-btn${mpt.id === current ? ' active' : ''}" onclick="selectMealPlanType(${dow},'${slot}','${mpt.id}')">${mpt.icon} ${mpt.label}</button>`
).join(''); ).join('');
// Position vertically near the badge, centered horizontally (CSS handles centering) // Position vertically near the badge, centered horizontally (CSS handles centering)
const rect = badgeEl.getBoundingClientRect(); const rect = badgeEl.getBoundingClientRect();
@@ -8666,43 +8683,70 @@ function resetMealPlan() {
} }
// ===== RECIPE GENERATION ===== // ===== RECIPE GENERATION =====
const MEAL_TYPES = [ const MEAL_TYPE_DEFS = [
{ id: 'colazione', icon: '☀️', label: t('meal_types.colazione'), from: 6, to: 11 }, { id: 'colazione', icon: '☀️', i18nKey: 'meal_types.colazione', from: 6, to: 11 },
{ id: 'pranzo', icon: '🍽️', label: t('meal_types.pranzo'), from: 11, to: 14 }, { id: 'pranzo', icon: '🍽️', i18nKey: 'meal_types.pranzo', from: 11, to: 14 },
{ id: 'merenda', icon: '🍪', label: t('meal_types.merenda'), from: 14, to: 17 }, { id: 'merenda', icon: '🍪', i18nKey: 'meal_types.merenda', from: 14, to: 17 },
{ id: 'cena', icon: '🌙', label: t('meal_types.cena'), from: 17, to: 6 }, { id: 'cena', icon: '🌙', i18nKey: 'meal_types.cena', from: 17, to: 6 },
{ id: 'dolce', icon: '🍰', label: t('meal_types.dolce'), from: -1, to: -1 }, { id: 'dolce', icon: '🍰', i18nKey: 'meal_types.dolce', from: -1, to: -1 },
{ id: 'succo', icon: '🧃', label: t('meal_types.succo'), from: -1, to: -1 }, { id: 'succo', icon: '🧃', i18nKey: 'meal_types.succo', from: -1, to: -1 },
]; ];
const MEAL_SUB_TYPES = { function getMealTypes() {
dolce: [ return MEAL_TYPE_DEFS.map(m => ({ ...m, label: t(m.i18nKey) }));
{ id: 'torta', icon: '🎂', label: t('meal_sub.dolce_torta') }, }
{ id: 'crema', icon: '🍮', label: t('meal_sub.dolce_crema') },
{ id: 'crumble', icon: '🥧', label: t('meal_sub.dolce_crumble') }, function getMealSubTypes() {
{ id: 'biscotti', icon: '🍪', label: t('meal_sub.dolce_biscotti') }, return {
{ id: 'frutta', icon: '🍓', label: t('meal_sub.dolce_frutta') }, dolce: [
], { id: 'torta', icon: '🎂', label: t('meal_sub.dolce_torta') },
succo: [ { id: 'crema', icon: '🍮', label: t('meal_sub.dolce_crema') },
{ id: 'dolce', icon: '🍑', label: t('meal_sub.succo_dolce') }, { id: 'crumble', icon: '🥧', label: t('meal_sub.dolce_crumble') },
{ id: 'energizzante', icon: '', label: t('meal_sub.succo_energizzante') }, { id: 'biscotti', icon: '🍪', label: t('meal_sub.dolce_biscotti') },
{ id: 'detox', icon: '🥬', label: t('meal_sub.succo_detox') }, { id: 'frutta', icon: '🍓', label: t('meal_sub.dolce_frutta') },
{ id: 'rinfrescante', icon: '🧊', label: t('meal_sub.succo_rinfrescante') }, ],
{ id: 'vitaminico', icon: '🍊', label: t('meal_sub.succo_vitaminico') }, succo: [
] { id: 'dolce', icon: '🍑', label: t('meal_sub.succo_dolce') },
}; { id: 'energizzante', icon: '⚡', label: t('meal_sub.succo_energizzante') },
{ id: 'detox', icon: '🥬', label: t('meal_sub.succo_detox') },
{ id: 'rinfrescante', icon: '🧊', label: t('meal_sub.succo_rinfrescante') },
{ id: 'vitaminico', icon: '🍊', label: t('meal_sub.succo_vitaminico') },
]
};
}
function getMealLabels() {
const labels = {};
getMealTypes().forEach(m => { labels[m.id] = `${m.icon} ${m.label}`; });
return labels;
}
function getMealType() { function getMealType() {
const hour = new Date().getHours(); const hour = new Date().getHours();
for (const m of MEAL_TYPES) { for (const m of MEAL_TYPE_DEFS) {
if (m.from < m.to) { if (hour >= m.from && hour < m.to) return m.id; } if (m.from < m.to) { if (hour >= m.from && hour < m.to) return m.id; }
else { if (hour >= m.from || hour < m.to) return m.id; } else { if (hour >= m.from || hour < m.to) return m.id; }
} }
return 'cena'; return 'cena';
} }
const MEAL_LABELS = {}; function _normalizeMealId(rawMeal) {
MEAL_TYPES.forEach(m => { MEAL_LABELS[m.id] = `${m.icon} ${m.label}`; }); if (!rawMeal) return '';
let meal = String(rawMeal).trim().toLowerCase();
meal = meal.replace(/^meal_types?\./, '');
if (meal === 'lunch') return 'pranzo';
if (meal === 'dinner') return 'cena';
return meal;
}
function _mealLabel(rawMeal) {
const mealId = _normalizeMealId(rawMeal);
const labels = getMealLabels();
if (labels[mealId]) return labels[mealId];
const translated = mealId ? t(`meal_types.${mealId}`) : '';
if (translated && translated !== `meal_types.${mealId}`) return translated;
return mealId || String(rawMeal || '');
}
function getSelectedMealType() { function getSelectedMealType() {
const checked = document.querySelector('input[name="recipe-meal"]:checked'); const checked = document.querySelector('input[name="recipe-meal"]:checked');
@@ -8778,7 +8822,7 @@ async function loadRecipeArchive() {
for (const entry of entries) { for (const entry of entries) {
const r = entry.recipe; const r = entry.recipe;
const mealIcon = MEAL_LABELS[r.meal] || r.meal; const mealIcon = _mealLabel(r.meal || entry.meal);
const tags = (r.tags || []).slice(0, 3).join(', '); const tags = (r.tags || []).slice(0, 3).join(', ');
// Find this entry's index in the flat archive array // Find this entry's index in the flat archive array
const archiveIdx = archive.indexOf(entry); const archiveIdx = archive.indexOf(entry);
@@ -8804,7 +8848,7 @@ async function loadRecipeArchive() {
function viewArchivedRecipe(idx) { function viewArchivedRecipe(idx) {
const entry = _recipeArchiveEntries[idx]; const entry = _recipeArchiveEntries[idx];
if (!entry) return; if (!entry) return;
_cachedRecipe = { meal: entry.meal, recipe: entry.recipe }; _cachedRecipe = { meal: _normalizeMealId(entry.meal), recipe: entry.recipe };
renderRecipe(entry.recipe); renderRecipe(entry.recipe);
document.getElementById('recipe-overlay').style.display = 'flex'; document.getElementById('recipe-overlay').style.display = 'flex';
document.getElementById('recipe-ask').style.display = 'none'; document.getElementById('recipe-ask').style.display = 'none';
@@ -8825,7 +8869,7 @@ function openRecipeDialog() {
// Build meal selector radios // Build meal selector radios
const mealGrid = document.getElementById('recipe-meal-grid'); const mealGrid = document.getElementById('recipe-meal-grid');
if (mealGrid) { if (mealGrid) {
mealGrid.innerHTML = MEAL_TYPES.map(m => { mealGrid.innerHTML = getMealTypes().map(m => {
const checked = m.id === meal ? ' checked' : ''; const checked = m.id === meal ? ' checked' : '';
return `<label class="recipe-meal-chip"><input type="radio" name="recipe-meal" value="${m.id}"${checked}> ${m.icon} ${m.label}</label>`; return `<label class="recipe-meal-chip"><input type="radio" name="recipe-meal" value="${m.id}"${checked}> ${m.icon} ${m.label}</label>`;
}).join(''); }).join('');
@@ -8899,7 +8943,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
const items = (data.inventory || []).filter(i => i.product_id == productId); const items = (data.inventory || []).filter(i => i.product_id == productId);
if (items.length === 0) { if (items.length === 0) {
showToast('⚠️ Prodotto non trovato in inventario', 'error'); showToast(t('error.not_in_inventory'), 'error');
return; return;
} }
@@ -8946,9 +8990,9 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
qtySection = ` qtySection = `
<div class="use-unit-switch" style="display:flex;margin-bottom:8px"> <div class="use-unit-switch" style="display:flex;margin-bottom:8px">
<button type="button" class="use-unit-btn active" id="ruse-unit-sub" onclick="switchRecipeUseUnit('sub')">${subLabel}</button> <button type="button" class="use-unit-btn active" id="ruse-unit-sub" onclick="switchRecipeUseUnit('sub')">${subLabel}</button>
<button type="button" class="use-unit-btn" id="ruse-unit-conf" onclick="switchRecipeUseUnit('conf')">Confezioni</button> <button type="button" class="use-unit-btn" id="ruse-unit-conf" onclick="switchRecipeUseUnit('conf')">${t('recipes.packs_label')}</button>
</div> </div>
<p id="ruse-hint" style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">Quantità in ${subLabel} (totale: ${Math.round(totalSub)}${subLabel})</p> <p id="ruse-hint" style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.quantity_in_total').replace('{unit}', subLabel).replace('{total}', Math.round(totalSub) + subLabel)}</p>
<div class="qty-control"> <div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)"></button> <button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)"></button>
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${step}" step="${step}" class="qty-input" <input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${step}" step="${step}" class="qty-input"
@@ -8961,7 +9005,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
const unitLabel = unitLabels[unit] || unit; const unitLabel = unitLabels[unit] || unit;
const inputMin = '0.1'; const inputMin = '0.1';
qtySection = ` qtySection = `
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">Quantità da usare (${unitLabel}):</p> <p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.amount_label')} (${unitLabel}):</p>
<div class="qty-control"> <div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)"></button> <button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)"></button>
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${inputMin}" step="any" class="qty-input" <input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${inputMin}" step="any" class="qty-input"
@@ -8991,34 +9035,34 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;display:none" id="ruse-scale-confirm-wrap"> <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;display:none" id="ruse-scale-confirm-wrap">
<div id="ruse-scale-confirm-bar" style="height:100%;width:100%;background:#22c55e;transition:none;border-radius:2px"></div> <div id="ruse-scale-confirm-bar" style="height:100%;width:100%;background:#22c55e;transition:none;border-radius:2px"></div>
</div> </div>
<div id="ruse-scale-live-label" class="scale-live-label" style="margin-top:3px">Attendi 10s di stabilità per la compilazione automatica</div> <div id="ruse-scale-live-label" class="scale-live-label" style="margin-top:3px">${t('recipes.scale_wait_stable')}</div>
</div>` : ''; </div>` : '';
document.getElementById('modal-content').innerHTML = ` document.getElementById('modal-content').innerHTML = `
<div class="modal-header"> <div class="modal-header">
<h3>📤 Usa ingrediente</h3> <h3>📤 ${t('recipes.use_ingredient_title')}</h3>
<button class="modal-close" onclick="closeModal()"></button> <button class="modal-close" onclick="closeModal()"></button>
</div> </div>
<div style="padding:0 16px 16px"> <div style="padding:0 16px 16px">
<p style="margin-bottom:4px;font-weight:600">${escapeHtml(items[0].name)}</p> <p style="margin-bottom:4px;font-weight:600">${escapeHtml(items[0].name)}</p>
${recipeQty ? `<p style="margin-bottom:8px;background:var(--bg-elevated,rgba(124,58,237,0.12));border-left:3px solid var(--color-accent,#7c3aed);border-radius:6px;padding:6px 10px;font-size:0.9rem">📋 Ricetta: <strong>${escapeHtml(recipeQty)}</strong></p>` : ''} ${recipeQty ? `<p style="margin-bottom:8px;background:var(--bg-elevated,rgba(124,58,237,0.12));border-left:3px solid var(--color-accent,#7c3aed);border-radius:6px;padding:6px 10px;font-size:0.9rem">📋 ${t('recipes.recipe_qty_label')}: <strong>${escapeHtml(recipeQty)}</strong></p>` : ''}
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:12px">📦 ${availInfo}</p> <p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:12px">📦 ${availInfo}</p>
${scaleLiveSection} ${scaleLiveSection}
<div class="form-group"> <div class="form-group">
<label>📍 Da dove?</label> <label>📍 ${t('recipes.from_where_label')}</label>
<div class="location-selector">${locButtons}</div> <div class="location-selector">${locButtons}</div>
<input type="hidden" id="ruse-location" value="${defaultLoc}"> <input type="hidden" id="ruse-location" value="${defaultLoc}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Quanto?</label> <label>${t('recipes.amount_label')}?</label>
${qtySection} ${qtySection}
<small id="ruse-scale-hint" style="display:none; color: var(--color-accent, #7c3aed); margin-top:4px"></small> <small id="ruse-scale-hint" style="display:none; color: var(--color-accent, #7c3aed); margin-top:4px"></small>
</div> </div>
<button type="button" id="btn-ruse-submit" class="btn btn-large btn-danger full-width move-countdown-btn" onclick="submitRecipeUse(false)" style="margin-top:8px"> <button type="button" id="btn-ruse-submit" class="btn btn-large btn-danger full-width move-countdown-btn" onclick="submitRecipeUse(false)" style="margin-top:8px">
📤 Usa questa quantità 📤 ${t('recipes.use_amount_btn')}
</button> </button>
<button type="button" class="btn btn-large btn-secondary full-width" style="margin-top:8px" onclick="submitRecipeUse(true)"> <button type="button" class="btn btn-large btn-secondary full-width" style="margin-top:8px" onclick="submitRecipeUse(true)">
🗑 Usa TUTTO / Finito 🗑 ${t('recipes.use_all_btn')}
</button> </button>
</div> </div>
`; `;
@@ -9026,7 +9070,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
} catch (err) { } catch (err) {
console.error('useRecipeIngredient error:', err); console.error('useRecipeIngredient error:', err);
showToast('Errore nel caricamento', 'error'); showToast(t('recipes.load_error'), 'error');
} }
} }
@@ -9051,7 +9095,7 @@ function switchRecipeUseUnit(mode) {
qtyInput.value = _recipeUseContext.qtyNumber || step; qtyInput.value = _recipeUseContext.qtyNumber || step;
qtyInput.step = step; qtyInput.step = step;
qtyInput.min = step; qtyInput.min = step;
hint.textContent = `Quantità in ${_recipeUseConfMode.subLabel} (totale: ${Math.round(_recipeUseConfMode.totalSub)}${_recipeUseConfMode.subLabel})`; hint.textContent = t('recipes.quantity_in_total').replace('{unit}', _recipeUseConfMode.subLabel).replace('{total}', Math.round(_recipeUseConfMode.totalSub) + _recipeUseConfMode.subLabel);
} else { } else {
confBtn.classList.add('active'); confBtn.classList.add('active');
subBtn.classList.remove('active'); subBtn.classList.remove('active');
@@ -9059,7 +9103,7 @@ function switchRecipeUseUnit(mode) {
qtyInput.value = 1; qtyInput.value = 1;
qtyInput.step = 0.5; qtyInput.step = 0.5;
qtyInput.min = 0.5; qtyInput.min = 0.5;
hint.textContent = `Confezioni da ${_recipeUseConfMode.packageSize}${_recipeUseConfMode.subLabel} (hai ${_recipeUseConfMode.totalConf.toFixed(1)} conf)`; hint.textContent = t('recipes.packs_of_have').replace('{size}', `${_recipeUseConfMode.packageSize}${_recipeUseConfMode.subLabel}`).replace('{count}', _recipeUseConfMode.totalConf.toFixed(1));
} }
} }
@@ -9124,9 +9168,9 @@ async function submitRecipeUse(useAll) {
saveRecipeToArchive(_cachedRecipe.recipe); saveRecipeToArchive(_cachedRecipe.recipe);
} }
showToast('📦 Ingrediente scalato dalla dispensa!', 'success'); showToast(t('recipes.ingredient_scaled_toast'), 'success');
if (result.added_to_bring) { if (result.added_to_bring) {
setTimeout(() => showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'), 1500); setTimeout(() => showToast(t('recipes.finished_added_bring_toast'), 'info'), 1500);
} }
// Check low stock → Bring! prompt, then offer move // Check low stock → Bring! prompt, then offer move
@@ -9219,8 +9263,8 @@ function renderRecipe(r) {
// Meta tags // Meta tags
html += '<div class="recipe-meta">'; html += '<div class="recipe-meta">';
html += `<span class="recipe-tag">${MEAL_LABELS[r.meal] || r.meal}</span>`; html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
html += `<span class="recipe-tag">👥 ${r.persons} pers.</span>`; html += `<span class="recipe-tag">👥 ${r.persons} ${t('recipes.persons_short')}</span>`;
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`; if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`; if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
if (r.tags) r.tags.forEach(t => { html += `<span class="recipe-tag">${t}</span>`; }); if (r.tags) r.tags.forEach(t => { html += `<span class="recipe-tag">${t}</span>`; });
@@ -9232,7 +9276,7 @@ function renderRecipe(r) {
} }
// Ingredients // Ingredients
html += '<h3>🧾 Ingredienti</h3><ul class="recipe-ingredients">'; html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
(r.ingredients || []).forEach((ing, idx) => { (r.ingredients || []).forEach((ing, idx) => {
if (ing.from_pantry && ing.product_id) { if (ing.from_pantry && ing.product_id) {
const qtyNum = ing.qty_number || 0; const qtyNum = ing.qty_number || 0;
@@ -9269,7 +9313,7 @@ function renderRecipe(r) {
html += '</ul>'; html += '</ul>';
// Steps // Steps
html += '<h3>👨‍🍳 Procedimento</h3><ol>'; html += `<h3>${t('recipes.steps_title')}</h3><ol>`;
(r.steps || []).forEach(step => { (r.steps || []).forEach(step => {
const cleanStep = step.replace(/^Passo\s*\d+\s*:\s*/i, ''); const cleanStep = step.replace(/^Passo\s*\d+\s*:\s*/i, '');
html += `<li>${cleanStep}</li>`; html += `<li>${cleanStep}</li>`;
@@ -9293,7 +9337,7 @@ let _cookingVisited = new Set(); // indices of steps already seen
function startCookingMode() { function startCookingMode() {
const recipe = _cachedRecipe && _cachedRecipe.recipe ? _cachedRecipe.recipe : null; const recipe = _cachedRecipe && _cachedRecipe.recipe ? _cachedRecipe.recipe : null;
if (!recipe || !(recipe.steps || []).length) { if (!recipe || !(recipe.steps || []).length) {
showToast('Nessun procedimento disponibile', 'info'); showToast(t('recipes.no_steps'), 'info');
return; return;
} }
// Resume if same recipe; otherwise start fresh // Resume if same recipe; otherwise start fresh
@@ -9383,7 +9427,7 @@ function renderCookingStep() {
<span class="cooking-ing-name">📦 <strong>${escapeHtml(ing.name)}</strong>: ${escapeHtml(ing.qty)}</span> <span class="cooking-ing-name">📦 <strong>${escapeHtml(ing.name)}</strong>: ${escapeHtml(ing.qty)}</span>
<div class="cooking-ing-meta">${chips.join('')}</div> <div class="cooking-ing-meta">${chips.join('')}</div>
</div> </div>
<button class="cooking-use-btn" onclick="cookingUseIngredient(${ing._idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this)">📤 Usa</button> <button class="cooking-use-btn" onclick="cookingUseIngredient(${ing._idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this)">${t('cooking.ingredient_use_btn')}</button>
</div>`; </div>`;
}).join(''); }).join('');
ingsEl.style.display = 'flex'; ingsEl.style.display = 'flex';
@@ -9396,7 +9440,7 @@ function renderCookingStep() {
const prevBtn = document.getElementById('cooking-prev'); const prevBtn = document.getElementById('cooking-prev');
const nextBtn = document.getElementById('cooking-next'); const nextBtn = document.getElementById('cooking-next');
prevBtn.disabled = _cookingStep === 0; prevBtn.disabled = _cookingStep === 0;
nextBtn.textContent = _cookingStep === total - 1 ? '✅ Fine' : 'Successivo ▶'; nextBtn.textContent = _cookingStep === total - 1 ? t('cooking.finish') : t('cooking.next');
// Timer: detect duration in step text and show suggestion // Timer: detect duration in step text and show suggestion
setupCookingTimerSuggestion(cleanStep); setupCookingTimerSuggestion(cleanStep);
@@ -9894,7 +9938,8 @@ function cookingUseIngredient(idx, productId, location, qtyNumber, btn) {
function updateRecipeMealTitle() { function updateRecipeMealTitle() {
const meal = getSelectedMealType(); const meal = getSelectedMealType();
document.getElementById('recipe-meal-title').textContent = MEAL_LABELS[meal] || '🍳 Ricetta'; const mealLabels = getMealLabels();
document.getElementById('recipe-meal-title').textContent = mealLabels[meal] || t('recipes.dialog_title');
_renderMealPlanHint(meal); _renderMealPlanHint(meal);
_renderMealSubTypes(meal); _renderMealSubTypes(meal);
} }
@@ -9902,7 +9947,7 @@ function updateRecipeMealTitle() {
function _renderMealSubTypes(mealId) { function _renderMealSubTypes(mealId) {
const container = document.getElementById('recipe-subtype-group'); const container = document.getElementById('recipe-subtype-group');
if (!container) return; if (!container) return;
const subs = MEAL_SUB_TYPES[mealId]; const subs = getMealSubTypes()[mealId];
if (!subs) { if (!subs) {
container.style.display = 'none'; container.style.display = 'none';
container.innerHTML = ''; container.innerHTML = '';
@@ -9944,7 +9989,7 @@ function _renderMealPlanHint(mealSlot) {
if (chipWrap) chipWrap.style.display = 'none'; if (chipWrap) chipWrap.style.display = 'none';
return; return;
} }
const mpt = MEAL_PLAN_TYPE_MAP[typeId]; const mpt = getMealPlanTypeMap()[typeId];
if (!mpt) { if (!mpt) {
if (el) el.style.display = 'none'; if (el) el.style.display = 'none';
if (banner) banner.style.display = 'none'; if (banner) banner.style.display = 'none';
@@ -10027,7 +10072,8 @@ async function generateRecipe() {
const payload = { const payload = {
meal, meal,
persons, persons,
sub_type: MEAL_SUB_TYPES[meal] ? getSelectedSubType() : '', lang: _currentLang,
sub_type: getMealSubTypes()[meal] ? getSelectedSubType() : '',
options, options,
appliances: settings.appliances || [], appliances: settings.appliances || [],
dietary_restrictions: settings.dietary_restrictions || '', dietary_restrictions: settings.dietary_restrictions || '',
@@ -10048,9 +10094,9 @@ async function generateRecipe() {
document.getElementById('recipe-loading').style.display = 'none'; document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = ''; document.getElementById('recipe-ask').style.display = '';
if (data.error === 'no_api_key') { if (data.error === 'no_api_key') {
showToast('⚠️ Chiave API Gemini non configurata', 'warning'); showToast(t('error.no_api_key'), 'warning');
} else { } else {
showToast(data.error || t('error.connection'), 'error'); showToast(data.error || t('recipes.generate_error'), 'error');
} }
return; return;
} }
@@ -10094,10 +10140,10 @@ async function generateRecipe() {
document.getElementById('recipe-ask').style.display = ''; document.getElementById('recipe-ask').style.display = '';
if (errorEvent) { if (errorEvent) {
if (errorEvent.error === 'no_api_key') { if (errorEvent.error === 'no_api_key') {
showToast('⚠️ Chiave API Gemini non configurata', 'warning'); showToast(t('error.no_api_key'), 'warning');
} else { } else {
const detail = errorEvent.detail ? ` (${errorEvent.detail})` : ''; const detail = errorEvent.detail ? ` (${errorEvent.detail})` : '';
showToast((errorEvent.error || 'Errore nella generazione') + detail, 'error'); showToast((errorEvent.error || t('recipes.generate_error')) + detail, 'error');
} }
} else { } else {
showToast(t('error.connection'), 'error'); showToast(t('error.connection'), 'error');
@@ -10348,7 +10394,7 @@ function updateScreensaverMealPlan() {
const slot = hour < 15 ? 'pranzo' : 'cena'; const slot = hour < 15 ? 'pranzo' : 'cena';
const typeId = getTodayMealPlanType(slot); const typeId = getTodayMealPlanType(slot);
if (!typeId || typeId === 'libero') { el.style.display = 'none'; return; } if (!typeId || typeId === 'libero') { el.style.display = 'none'; return; }
const mpt = MEAL_PLAN_TYPE_MAP[typeId]; const mpt = getMealPlanTypeMap()[typeId];
if (!mpt) { el.style.display = 'none'; return; } if (!mpt) { el.style.display = 'none'; return; }
const slotLabel = slot === 'pranzo' ? '🌤️ ' + t('meal_types.pranzo') : '🌙 ' + t('meal_types.cena'); const slotLabel = slot === 'pranzo' ? '🌤️ ' + t('meal_types.pranzo') : '🌙 ' + t('meal_types.cena');
el.innerHTML = `<span class="screensaver-mealplan-badge">${slotLabel} · ${mpt.icon} ${mpt.label}</span>`; el.innerHTML = `<span class="screensaver-mealplan-badge">${slotLabel} · ${mpt.icon} ${mpt.label}</span>`;
@@ -11300,7 +11346,7 @@ async function spesaLogin() {
const provider = s.spesa_provider || 'dupliclick'; const provider = s.spesa_provider || 'dupliclick';
if (!email || !password) { if (!email || !password) {
showToast('Inserisci email e password', 'error'); showToast(t('settings.spesa.missing_credentials'), 'error');
return; return;
} }
@@ -11309,7 +11355,7 @@ async function spesaLogin() {
const resultEl = document.getElementById('spesa-login-result'); const resultEl = document.getElementById('spesa-login-result');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '⏳ Accesso in corso...'; btn.innerHTML = `${t('settings.spesa.login_in_progress')}`;
statusEl.style.display = 'none'; statusEl.style.display = 'none';
resultEl.style.display = 'none'; resultEl.style.display = 'none';
@@ -11318,10 +11364,10 @@ async function spesaLogin() {
if (res.error) { if (res.error) {
statusEl.className = 'dupliclick-status error'; statusEl.className = 'dupliclick-status error';
statusEl.innerHTML = `❌ <strong>Errore:</strong> ${escapeHtml(res.error)}`; statusEl.innerHTML = `❌ <strong>${t('settings.spesa.login_error_prefix')}</strong> ${escapeHtml(res.error)}`;
statusEl.style.display = 'block'; statusEl.style.display = 'block';
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '🔐 Accedi'; btn.innerHTML = t('settings.spesa.login_btn');
return; return;
} }
@@ -11339,7 +11385,7 @@ async function spesaLogin() {
saveSettingsToStorage(s); saveSettingsToStorage(s);
statusEl.className = 'dupliclick-status success'; statusEl.className = 'dupliclick-status success';
const welcomeMsg = (res.infos && res.infos[0]) ? res.infos[0].info : 'Login effettuato!'; const welcomeMsg = (res.infos && res.infos[0]) ? res.infos[0].info : t('settings.spesa.login_success_default');
statusEl.innerHTML = `✅ <strong>${escapeHtml(welcomeMsg)}</strong>`; statusEl.innerHTML = `✅ <strong>${escapeHtml(welcomeMsg)}</strong>`;
statusEl.style.display = 'block'; statusEl.style.display = 'block';
@@ -11353,10 +11399,10 @@ async function spesaLogin() {
let html = '<div class="dupliclick-data">'; let html = '<div class="dupliclick-data">';
html += '<div class="dupliclick-data-grid">'; html += '<div class="dupliclick-data-grid">';
if (user.firstName) html += `<div class="data-row"><span class="data-label">👤 Nome</span><span class="data-value">${escapeHtml(user.firstName)} ${escapeHtml(user.lastName || '')}</span></div>`; if (user.firstName) html += `<div class="data-row"><span class="data-label">👤 ${t('settings.spesa.result_name_label')}</span><span class="data-value">${escapeHtml(user.firstName)} ${escapeHtml(user.lastName || '')}</span></div>`;
if (user.fidelityCard) html += `<div class="data-row"><span class="data-label">💳 Tessera</span><span class="data-value">${escapeHtml(user.fidelityCard)}</span></div>`; if (user.fidelityCard) html += `<div class="data-row"><span class="data-label">💳 ${t('settings.spesa.result_card_label')}</span><span class="data-value">${escapeHtml(user.fidelityCard)}</span></div>`;
if (shipping.addressName) html += `<div class="data-row"><span class="data-label">🏪 Punto Ritiro</span><span class="data-value">${escapeHtml(shipping.addressName)}</span></div>`; if (shipping.addressName) html += `<div class="data-row"><span class="data-label">🏪 ${t('settings.spesa.result_pickup_label')}</span><span class="data-value">${escapeHtml(shipping.addressName)}</span></div>`;
if (fidelityPts) html += `<div class="data-row"><span class="data-label">⭐ Punti Fedeltà</span><span class="data-value">${fidelityPts.value || 0}</span></div>`; if (fidelityPts) html += `<div class="data-row"><span class="data-label">⭐ ${t('settings.spesa.result_points_label')}</span><span class="data-value">${fidelityPts.value || 0}</span></div>`;
html += '</div></div>'; html += '</div></div>';
resultEl.innerHTML = html; resultEl.innerHTML = html;
@@ -11364,12 +11410,12 @@ async function spesaLogin() {
} catch (e) { } catch (e) {
statusEl.className = 'dupliclick-status error'; statusEl.className = 'dupliclick-status error';
statusEl.innerHTML = `❌ <strong>Errore di rete:</strong> ${escapeHtml(e.message)}`; statusEl.innerHTML = `❌ <strong>${t('settings.spesa.login_network_error_prefix')}</strong> ${escapeHtml(e.message)}`;
statusEl.style.display = 'block'; statusEl.style.display = 'block';
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '🔐 Accedi'; btn.innerHTML = t('settings.spesa.login_btn');
} }
function loadSpesaSettings() { function loadSpesaSettings() {
@@ -11388,12 +11434,12 @@ function loadSpesaSettings() {
const loginBtn = document.getElementById('spesa-login-btn'); const loginBtn = document.getElementById('spesa-login-btn');
if (loginBtn) { if (loginBtn) {
loginBtn.innerHTML = '✅ Connesso — Riaccedi'; loginBtn.innerHTML = t('settings.spesa.connected_relogin');
loginBtn.className = 'btn btn-large btn-secondary full-width mt-2'; loginBtn.className = 'btn btn-large btn-secondary full-width mt-2';
} }
if (statusEl) { if (statusEl) {
statusEl.className = 'dupliclick-status success'; statusEl.className = 'dupliclick-status success';
statusEl.innerHTML = `✅ <strong>Connesso come ${escapeHtml(s.spesa_user.firstName || '')} ${escapeHtml(s.spesa_user.lastName || '')}</strong>`; statusEl.innerHTML = `✅ <strong>${t('settings.spesa.connected_as').replace('{name}', `${escapeHtml(s.spesa_user.firstName || '')} ${escapeHtml(s.spesa_user.lastName || '')}`.trim())}</strong>`;
statusEl.style.display = 'block'; statusEl.style.display = 'block';
} }
if (resultEl) { if (resultEl) {
@@ -11403,10 +11449,10 @@ function loadSpesaSettings() {
const fidelityPts = Array.isArray(points) ? points[0] : points['0']; const fidelityPts = Array.isArray(points) ? points[0] : points['0'];
let html = '<div class="dupliclick-data"><div class="dupliclick-data-grid">'; let html = '<div class="dupliclick-data"><div class="dupliclick-data-grid">';
if (user.firstName) html += `<div class="data-row"><span class="data-label">👤 Nome</span><span class="data-value">${escapeHtml(user.firstName)} ${escapeHtml(user.lastName || '')}</span></div>`; if (user.firstName) html += `<div class="data-row"><span class="data-label">👤 ${t('settings.spesa.result_name_label')}</span><span class="data-value">${escapeHtml(user.firstName)} ${escapeHtml(user.lastName || '')}</span></div>`;
if (user.fidelityCard) html += `<div class="data-row"><span class="data-label">💳 Tessera</span><span class="data-value">${escapeHtml(user.fidelityCard)}</span></div>`; if (user.fidelityCard) html += `<div class="data-row"><span class="data-label">💳 ${t('settings.spesa.result_card_label')}</span><span class="data-value">${escapeHtml(user.fidelityCard)}</span></div>`;
if (shipping.addressName) html += `<div class="data-row"><span class="data-label">🏪 Punto Ritiro</span><span class="data-value">${escapeHtml(shipping.addressName)}</span></div>`; if (shipping.addressName) html += `<div class="data-row"><span class="data-label">🏪 ${t('settings.spesa.result_pickup_label')}</span><span class="data-value">${escapeHtml(shipping.addressName)}</span></div>`;
if (fidelityPts) html += `<div class="data-row"><span class="data-label">⭐ Punti Fedeltà</span><span class="data-value">${fidelityPts.value || 0}</span></div>`; if (fidelityPts) html += `<div class="data-row"><span class="data-label">⭐ ${t('settings.spesa.result_points_label')}</span><span class="data-value">${fidelityPts.value || 0}</span></div>`;
html += '</div></div>'; html += '</div></div>';
resultEl.innerHTML = html; resultEl.innerHTML = html;
resultEl.style.display = 'block'; resultEl.style.display = 'block';
+43 -43
View File
@@ -167,25 +167,25 @@
<div class="barcode-manual-entry"> <div class="barcode-manual-entry">
<div class="barcode-input-row"> <div class="barcode-input-row">
<input type="text" id="manual-barcode-input" class="form-input" placeholder="Inserisci codice a barre..." inputmode="numeric" pattern="[0-9]*" onkeydown="if(event.key==='Enter')submitManualBarcode()" data-i18n-placeholder="scan.barcode_placeholder"> <input type="text" id="manual-barcode-input" class="form-input" placeholder="Inserisci codice a barre..." inputmode="numeric" pattern="[0-9]*" onkeydown="if(event.key==='Enter')submitManualBarcode()" data-i18n-placeholder="scan.barcode_placeholder">
<button class="btn btn-primary" onclick="submitManualBarcode()">🔍 Cerca</button> <button class="btn btn-primary" onclick="submitManualBarcode()" data-i18n="btn.search">🔍 Cerca</button>
</div> </div>
</div> </div>
<div class="quick-name-entry"> <div class="quick-name-entry">
<div class="quick-name-divider"><span>oppure scrivi il nome</span></div> <div class="quick-name-divider"><span data-i18n="scan.quick_name_divider">oppure scrivi il nome</span></div>
<div class="barcode-input-row"> <div class="barcode-input-row">
<input type="text" id="quick-product-name" class="form-input" placeholder="Es: Mele, Zucchine, Pane..." list="common-products" autocomplete="off" onkeydown="if(event.key==='Enter')submitQuickName()"> <input type="text" id="quick-product-name" class="form-input" placeholder="Es: Mele, Zucchine, Pane..." list="common-products" autocomplete="off" onkeydown="if(event.key==='Enter')submitQuickName()" data-i18n-placeholder="scan.quick_name_placeholder">
<button class="btn btn-accent" onclick="submitQuickName()">✅ Vai</button> <button class="btn btn-accent" onclick="submitQuickName()" data-i18n="btn.go">✅ Vai</button>
</div> </div>
</div> </div>
<div class="scan-actions"> <div class="scan-actions">
<button class="btn btn-large btn-secondary" onclick="startManualEntry()"> <button class="btn btn-large btn-secondary" onclick="startManualEntry()" data-i18n="scan.manual_entry">
✏️ Inserimento Manuale ✏️ Inserimento Manuale
</button> </button>
<button class="btn btn-large btn-accent" onclick="captureForAI()"> <button class="btn btn-large btn-accent" onclick="captureForAI()" data-i18n="scan.ai_identify">
🤖 Identifica con AI 🤖 Identifica con AI
</button> </button>
</div> </div>
<p class="scan-hint">Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo</p> <p class="scan-hint" data-i18n="scan.hint">Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo</p>
<div id="scan-debug-log" style="display:none;margin-top:12px;padding:10px;background:#1a1a2e;color:#0f0;font-family:monospace;font-size:0.7rem;max-height:200px;overflow-y:auto;border-radius:8px;white-space:pre-wrap"></div> <div id="scan-debug-log" style="display:none;margin-top:12px;padding:10px;background:#1a1a2e;color:#0f0;font-family:monospace;font-size:0.7rem;max-height:200px;overflow-y:auto;border-radius:8px;white-space:pre-wrap"></div>
<button class="btn btn-small btn-secondary" style="margin-top:8px;opacity:0.5" onclick="toggleScanDebug()">🐛 Debug Log</button> <button class="btn btn-small btn-secondary" style="margin-top:8px;opacity:0.5" onclick="toggleScanDebug()">🐛 Debug Log</button>
</div> </div>
@@ -194,8 +194,8 @@
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== --> <!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
<section class="page" id="page-action"> <section class="page" id="page-action">
<div class="page-header"> <div class="page-header">
<button class="back-btn" id="action-back-btn" onclick="showPage('scan')">← Indietro</button> <button class="back-btn" id="action-back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
<h2>Cosa vuoi fare?</h2> <h2 data-i18n="action.title">Cosa vuoi fare?</h2>
</div> </div>
<!-- Banner: shopping list scan context --> <!-- Banner: shopping list scan context -->
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div> <div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
@@ -560,10 +560,10 @@
<!-- Tab navigation --> <!-- Tab navigation -->
<div class="shopping-tabs" id="shopping-tabs" style="display:none"> <div class="shopping-tabs" id="shopping-tabs" style="display:none">
<button class="shopping-tab active" id="tab-acquisto" onclick="switchShoppingTab('acquisto')"> <button class="shopping-tab active" id="tab-acquisto" onclick="switchShoppingTab('acquisto')">
🛍️ Da comprare <span class="shopping-tab-count" id="tab-count-acquisto">0</span> <span data-i18n="shopping.tab_to_buy">🛍️ Da comprare</span> <span class="shopping-tab-count" id="tab-count-acquisto">0</span>
</button> </button>
<button class="shopping-tab" id="tab-previsione" onclick="switchShoppingTab('previsione')"> <button class="shopping-tab" id="tab-previsione" onclick="switchShoppingTab('previsione')">
🧠 In previsione <span class="shopping-tab-count" id="tab-count-previsione">0</span> <span data-i18n="shopping.tab_forecast">🧠 In previsione</span> <span class="shopping-tab-count" id="tab-count-previsione">0</span>
</button> </button>
</div> </div>
@@ -579,31 +579,31 @@
</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>🛍️ Da comprare</h3> <h3 data-i18n="shopping.section_to_buy">🛍️ Da comprare</h3>
<span class="shopping-count" id="shopping-count">0</span> <span class="shopping-count" id="shopping-count">0</span>
</div> </div>
<div class="shopping-items" id="shopping-items"></div> <div class="shopping-items" id="shopping-items"></div>
</div> </div>
<div class="shopping-suggestions" id="shopping-suggestions" style="display:none"> <div class="shopping-suggestions" id="shopping-suggestions" style="display:none">
<div class="shopping-section-header"> <div class="shopping-section-header">
<h3>💡 Suggerimenti AI</h3> <h3 data-i18n="shopping.suggestions_title">💡 Suggerimenti AI</h3>
</div> </div>
<div class="seasonal-tip" id="seasonal-tip" style="display:none"></div> <div class="seasonal-tip" id="seasonal-tip" style="display:none"></div>
<div class="suggestion-items" id="suggestion-items"></div> <div class="suggestion-items" id="suggestion-items"></div>
<div class="suggestion-actions" id="suggestion-actions" style="display:none"> <div class="suggestion-actions" id="suggestion-actions" style="display:none">
<button class="btn btn-success" onclick="addSelectedSuggestions()"> <button class="btn btn-success" onclick="addSelectedSuggestions()" data-i18n="shopping.bring_add_selected">
✅ Aggiungi selezionati a Bring! ✅ Aggiungi selezionati a Bring!
</button> </button>
</div> </div>
</div> </div>
<div class="shopping-actions"> <div class="shopping-actions">
<button class="btn btn-large btn-accent" onclick="searchAllPrices()" id="btn-search-prices"> <button class="btn btn-large btn-accent" onclick="searchAllPrices()" id="btn-search-prices" data-i18n="shopping.search_prices">
🔍 Cerca tutti i prezzi 🔍 Cerca tutti i prezzi
</button> </button>
<button class="btn btn-large btn-accent" onclick="generateSuggestions()" id="btn-suggest"> <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"> <button class="btn btn-secondary" onclick="forceSyncBring()" style="margin-top:4px" data-i18n="shopping.force_sync">
🔄 Forza sincronizzazione Bring! 🔄 Forza sincronizzazione Bring!
</button> </button>
</div> </div>
@@ -616,32 +616,32 @@
<div class="smart-shopping-empty" id="smart-shopping-empty" style="display:none"> <div class="smart-shopping-empty" id="smart-shopping-empty" style="display:none">
<div class="empty-state" style="padding:30px"> <div class="empty-state" style="padding:30px">
<div class="empty-state-icon">🧠</div> <div class="empty-state-icon">🧠</div>
<p>Nessuna previsione disponibile.<br>Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.</p> <p data-i18n-html="shopping.smart_empty">Nessuna previsione disponibile.<br>Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.</p>
</div> </div>
</div> </div>
<div id="smart-shopping-content"> <div id="smart-shopping-content">
<div class="shopping-section-header" style="margin-bottom:4px"> <div class="shopping-section-header" style="margin-bottom:4px">
<h3>🧠 Previsioni intelligenti</h3> <h3 data-i18n="shopping.smart_title">🧠 Previsioni intelligenti</h3>
<span class="shopping-count" id="smart-count">0</span> <span class="shopping-count" id="smart-count">0</span>
</div> </div>
<div class="smart-last-update-row"> <div class="smart-last-update-row">
<span id="smart-last-update" class="smart-last-update"></span> <span id="smart-last-update" class="smart-last-update"></span>
</div> </div>
<div class="smart-filter-row" id="smart-filter-row"> <div class="smart-filter-row" id="smart-filter-row">
<button class="smart-filter active" data-filter="all" onclick="filterSmart('all')">Tutti</button> <button class="smart-filter active" data-filter="all" onclick="filterSmart('all')" data-i18n="shopping.smart_filter_all">Tutti</button>
<button class="smart-filter" data-filter="critical" onclick="filterSmart('critical')">🔴 Urgenti</button> <button class="smart-filter" data-filter="critical" onclick="filterSmart('critical')" data-i18n="shopping.smart_filter_critical">🔴 Urgenti</button>
<button class="smart-filter" data-filter="high" onclick="filterSmart('high')">🟠 Presto</button> <button class="smart-filter" data-filter="high" onclick="filterSmart('high')" data-i18n="shopping.smart_filter_high">🟠 Presto</button>
<button class="smart-filter" data-filter="medium" onclick="filterSmart('medium')">🟡 Pianifica</button> <button class="smart-filter" data-filter="medium" onclick="filterSmart('medium')" data-i18n="shopping.smart_filter_medium">🟡 Pianifica</button>
<button class="smart-filter" data-filter="low" onclick="filterSmart('low')">🟢 Previsione</button> <button class="smart-filter" data-filter="low" onclick="filterSmart('low')" data-i18n="shopping.smart_filter_low">🟢 Previsione</button>
</div> </div>
<div class="smart-items" id="smart-items"></div> <div class="smart-items" id="smart-items"></div>
<div class="smart-actions" id="smart-actions" style="display:none"> <div class="smart-actions" id="smart-actions" style="display:none">
<button class="btn btn-success full-width" onclick="addSmartToBring()"> <button class="btn btn-success full-width" onclick="addSmartToBring()" data-i18n="shopping.smart_add">
🛒 Aggiungi selezionati a Bring! 🛒 Aggiungi selezionati a Bring!
</button> </button>
</div> </div>
<div style="text-align:center;margin-top:8px"> <div style="text-align:center;margin-top:8px">
<button class="btn btn-secondary btn-sm" onclick="forceSyncBring()" id="btn-force-sync"> <button class="btn btn-secondary btn-sm" onclick="forceSyncBring()" id="btn-force-sync" data-i18n="shopping.force_sync">
🔄 Forza sincronizzazione Bring! 🔄 Forza sincronizzazione Bring!
</button> </button>
</div> </div>
@@ -1161,16 +1161,16 @@
<div class="modal-content recipe-dialog" onclick="event.stopPropagation()"> <div class="modal-content recipe-dialog" onclick="event.stopPropagation()">
<div id="recipe-mealplan-banner" class="recipe-mealplan-banner" style="display:none"></div> <div id="recipe-mealplan-banner" class="recipe-mealplan-banner" style="display:none"></div>
<div id="recipe-ask" class="recipe-ask"> <div id="recipe-ask" class="recipe-ask">
<h3 id="recipe-meal-title">🍳 Ricetta</h3> <h3 id="recipe-meal-title" data-i18n="recipes.dialog_title">🍳 Ricetta</h3>
<p class="recipe-desc">Genero una ricetta sana con gli ingredienti in dispensa, dando priorità a quelli in scadenza.</p> <p class="recipe-desc" data-i18n="recipes.dialog_desc">Genero una ricetta sana con gli ingredienti in dispensa, dando priorità a quelli in scadenza.</p>
<div class="form-group" style="text-align:left"> <div class="form-group" style="text-align:left">
<label>🕐 Per quale pasto?</label> <label data-i18n="recipes.meal_label">🕐 Per quale pasto?</label>
<div class="recipe-meal-grid" id="recipe-meal-grid" onchange="updateRecipeMealTitle()"></div> <div class="recipe-meal-grid" id="recipe-meal-grid" onchange="updateRecipeMealTitle()"></div>
<div class="recipe-meal-grid recipe-subtype-grid" id="recipe-subtype-group" style="display:none"></div> <div class="recipe-meal-grid recipe-subtype-grid" id="recipe-subtype-group" style="display:none"></div>
</div> </div>
<div id="recipe-mealplan-hint" class="recipe-mealplan-hint" style="display:none"></div> <div id="recipe-mealplan-hint" class="recipe-mealplan-hint" style="display:none"></div>
<div class="form-group"> <div class="form-group">
<label>👥 Quante persone?</label> <label data-i18n="recipes.persons_label">👥 Quante persone?</label>
<div class="qty-control"> <div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustRecipePersons(-1)"></button> <button type="button" class="qty-btn" onclick="adjustRecipePersons(-1)"></button>
<input type="number" id="recipe-persons" value="1" min="1" max="20" class="qty-input"> <input type="number" id="recipe-persons" value="1" min="1" max="20" class="qty-input">
@@ -1178,37 +1178,37 @@
</div> </div>
</div> </div>
<div class="form-group" style="text-align:left"> <div class="form-group" style="text-align:left">
<label>🎯 Tipo di pasto</label> <label data-i18n="recipes.meal_type_label">🎯 Tipo di pasto</label>
<div class="recipe-options-grid"> <div class="recipe-options-grid">
<label class="recipe-option-chip recipe-opt-mealplan-chip" id="recipe-opt-mealplan-wrap" style="display:none"><input type="checkbox" id="recipe-opt-mealplan" checked onchange="onMealPlanChipChange(this)"> <span id="recipe-opt-mealplan-label"></span></label> <label class="recipe-option-chip recipe-opt-mealplan-chip" id="recipe-opt-mealplan-wrap" style="display:none"><input type="checkbox" id="recipe-opt-mealplan" checked onchange="onMealPlanChipChange(this)"> <span id="recipe-opt-mealplan-label"></span></label>
<label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-veloce"> ⚡ Pasto Veloce</label> <label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-veloce"> <span data-i18n="recipes.opt_fast">⚡ Pasto Veloce</span></label>
<label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-pocafame"> 🥗 Poca Fame</label> <label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-pocafame"> <span data-i18n="recipes.opt_light">🥗 Poca Fame</span></label>
<label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-scadenze"> ⏰ Priorità Scadenze</label> <label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-scadenze"> <span data-i18n="recipes.opt_expiry">⏰ Priorità Scadenze</span></label>
<label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-healthy"> 💚 Extra Salutare</label> <label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-healthy"> <span data-i18n="recipes.opt_healthy">💚 Extra Salutare</span></label>
<label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-opened"> 📦 Priorità Cose Aperte</label> <label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-opened"> <span data-i18n="recipes.opt_opened">📦 Priorità Cose Aperte</span></label>
<label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-zerowaste"> ♻️ Zero Sprechi</label> <label class="recipe-option-chip"><input type="checkbox" id="recipe-opt-zerowaste"> <span data-i18n="recipes.opt_zero_waste">♻️ Zero Sprechi</span></label>
</div> </div>
</div> </div>
<button class="btn btn-large btn-success full-width" onclick="generateRecipe()"> <button class="btn btn-large btn-success full-width" onclick="generateRecipe()" data-i18n="recipes.generate_btn">
✨ Genera Ricetta ✨ Genera Ricetta
</button> </button>
<button class="btn btn-large btn-secondary full-width mt-2" onclick="closeRecipeDialog()"> <button class="btn btn-large btn-secondary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="btn.cancel">
Annulla Annulla
</button> </button>
</div> </div>
<div id="recipe-loading" style="display:none" class="recipe-loading"> <div id="recipe-loading" style="display:none" class="recipe-loading">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<p id="recipe-loading-msg">Sto preparando la ricetta...</p> <p id="recipe-loading-msg" data-i18n="recipes.loading_msg">Sto preparando la ricetta...</p>
</div> </div>
<div id="recipe-result" style="display:none" class="recipe-result"> <div id="recipe-result" style="display:none" class="recipe-result">
<div id="recipe-content"></div> <div id="recipe-content"></div>
<button class="btn btn-large btn-cooking full-width mt-2" onclick="startCookingMode()"> <button class="btn btn-large btn-cooking full-width mt-2" onclick="startCookingMode()" data-i18n="recipes.start_cooking">
👨‍🍳 Modalità Cucina 👨‍🍳 Modalità Cucina
</button> </button>
<button class="btn btn-large btn-secondary full-width mt-2" onclick="regenerateRecipe()"> <button class="btn btn-large btn-secondary full-width mt-2" onclick="regenerateRecipe()" data-i18n="recipes.regenerate">
🔄 Generane un'altra 🔄 Generane un'altra
</button> </button>
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()"> <button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
✅ Chiudi ✅ Chiudi
</button> </button>
</div> </div>
+86 -6
View File
@@ -169,7 +169,9 @@
"hint": "Barcode scannen, Produktname eingeben oder KI zur Identifikation nutzen", "hint": "Barcode scannen, Produktname eingeben oder KI zur Identifikation nutzen",
"debug_toggle": "🐛 Debug Log", "debug_toggle": "🐛 Debug Log",
"barcode_acquired": "🔖 Barcode gescannt: {code}", "barcode_acquired": "🔖 Barcode gescannt: {code}",
"scan_barcode": "🔖 Barcode scannen" "scan_barcode": "🔖 Barcode scannen",
"create_named": "{name} erstellen",
"new_without_barcode": "Neues Produkt ohne Barcode"
}, },
"action": { "action": {
"title": "Was möchtest du tun?", "title": "Was möchtest du tun?",
@@ -212,7 +214,11 @@
"title": "Verwenden / Verbrauchen", "title": "Verwenden / Verbrauchen",
"location_label": "📍 Woher?", "location_label": "📍 Woher?",
"quantity_label": "Wie viel hast du benutzt?", "quantity_label": "Wie viel hast du benutzt?",
"change": "ändern",
"partial_hint": "Oder genaue Menge angeben:", "partial_hint": "Oder genaue Menge angeben:",
"partial_piece_hint": "Hast du nur einen Teil verwendet?",
"piece": "Stück",
"one_whole": "1 ganzes",
"use_all": "🗑️ ALLES verwendet / Aufgebraucht", "use_all": "🗑️ ALLES verwendet / Aufgebraucht",
"submit": "📤 Diese Menge verwenden", "submit": "📤 Diese Menge verwenden",
"available": "📦 Verfügbar:", "available": "📦 Verfügbar:",
@@ -273,7 +279,41 @@
"recipes": { "recipes": {
"title": "🍳 Rezepte", "title": "🍳 Rezepte",
"generate": "✨ Neues Rezept generieren", "generate": "✨ Neues Rezept generieren",
"archive_empty": "Keine Rezepte gespeichert. Erstelle dein erstes Rezept!" "archive_empty": "Keine Rezepte gespeichert. Erstelle dein erstes Rezept!",
"dialog_title": "🍳 Rezept",
"dialog_desc": "Ich erstelle ein gesundes Rezept mit Zutaten aus dem Vorrat und priorisiere Produkte mit nahendem Ablaufdatum.",
"meal_label": "🕐 Für welche Mahlzeit?",
"persons_label": "👥 Für wie viele Personen?",
"meal_type_label": "🎯 Art der Mahlzeit",
"opt_fast": "⚡ Schnelle Mahlzeit",
"opt_light": "🥗 Kleiner Hunger",
"opt_expiry": "⏰ Ablaufdaten priorisieren",
"opt_healthy": "💚 Extra gesund",
"opt_opened": "📦 Geöffnete Produkte priorisieren",
"opt_zero_waste": "♻️ Zero Waste",
"generate_btn": "✨ Rezept generieren",
"loading_msg": "Rezept wird vorbereitet...",
"start_cooking": "👨‍🍳 Kochmodus",
"regenerate": "🔄 Noch eins generieren",
"close_btn": "✅ Schließen",
"ingredients_title": "🧾 Zutaten",
"steps_title": "👨‍🍳 Zubereitung",
"no_steps": "Keine Zubereitungsschritte verfügbar",
"generate_error": "Fehler bei der Generierung",
"persons_short": "Pers.",
"use_ingredient_title": "Zutat verwenden",
"recipe_qty_label": "Rezept",
"from_where_label": "Von wo?",
"amount_label": "Wie viel",
"use_amount_btn": "Diese Menge verwenden",
"use_all_btn": "ALLES verwenden / Aufgebraucht",
"packs_label": "Packungen",
"quantity_in_total": "Menge in {unit} (gesamt: {total})",
"packs_of_have": "Packungen à {size} (du hast {count} Pack.)",
"scale_wait_stable": "10s stabiles Gewicht für Auto-Ausfüllen abwarten…",
"ingredient_scaled_toast": "📦 Zutat vom Vorrat abgezogen!",
"finished_added_bring_toast": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt!",
"load_error": "Fehler beim Laden"
}, },
"shopping": { "shopping": {
"title": "🛒 Einkaufsliste", "title": "🛒 Einkaufsliste",
@@ -333,7 +373,31 @@
"bring_adding": "Wird hinzugefügt...", "bring_adding": "Wird hinzugefügt...",
"bring_added_one": "1 Produkt zu Bring! hinzugefügt", "bring_added_one": "1 Produkt zu Bring! hinzugefügt",
"bring_added_many": "{n} Produkte zu Bring! hinzugefügt", "bring_added_many": "{n} Produkte zu Bring! hinzugefügt",
"bring_skipped": "({n} bereits in Liste)" "bring_skipped": "({n} bereits in Liste)",
"force_sync": "Bring!-Synchronisierung erzwingen",
"scan_target_label": "Du suchst",
"scan_target_found": "Gefunden! Aus Liste entfernen",
"bring_add_one": "1 Produkt zu Bring! hinzufügen",
"bring_add_many": "{n} Produkte zu Bring! hinzufügen",
"syncing": "Synchronisiere…",
"sync_done": "Synchronisierung abgeschlossen",
"price_searching": "Suche...",
"search_action": "Suchen",
"open_action": "Öffnen",
"not_found": "Nicht gefunden",
"search_price": "Preis suchen",
"tap_to_scan": "Zum Scannen tippen",
"tag_title": "Tag",
"remove_title": "Entfernen",
"found_count": "{found}/{total} Produkte gefunden",
"savings_offers": "· 🏷️ Du sparst €{amount} mit Angeboten",
"searching_progress": "Suche {current}/{total}...",
"remove_error": "Fehler beim Entfernen",
"suggest_loading": "Analyse läuft...",
"suggest_error": "Fehler bei der Vorschlagserstellung",
"priority_high": "Hoch",
"priority_medium": "Mittel",
"priority_low": "Niedrig"
}, },
"ai": { "ai": {
"title": "🤖 KI-Identifikation", "title": "🤖 KI-Identifikation",
@@ -394,7 +458,8 @@
"timer_expired_tts": "Timer {label} abgelaufen!", "timer_expired_tts": "Timer {label} abgelaufen!",
"timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!", "timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!",
"recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!", "recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!",
"expires_chip": "läuft ab {date}" "expires_chip": "läuft ab {date}",
"finish": "✅ Fertig"
}, },
"settings": { "settings": {
"title": "⚙️ Einstellungen", "title": "⚙️ Einstellungen",
@@ -468,7 +533,18 @@
"ai_prompt_label": "🤖 KI-Produktauswahl Prompt", "ai_prompt_label": "🤖 KI-Produktauswahl Prompt",
"ai_prompt_placeholder": "Anweisungen für die KI bei der Auswahl zwischen mehreren Produkten...", "ai_prompt_placeholder": "Anweisungen für die KI bei der Auswahl zwischen mehreren Produkten...",
"ai_prompt_hint": "Die KI verwendet diesen Prompt zur Auswahl des passendsten Produkts. Leer lassen für Standardverhalten.", "ai_prompt_hint": "Die KI verwendet diesen Prompt zur Auswahl des passendsten Produkts. Leer lassen für Standardverhalten.",
"configure_first": "Konfiguriere zuerst den Online-Einkauf in den Einstellungen" "configure_first": "Konfiguriere zuerst den Online-Einkauf in den Einstellungen",
"missing_credentials": "E-Mail und Passwort eingeben",
"login_in_progress": "Anmeldung läuft...",
"login_error_prefix": "Fehler:",
"login_network_error_prefix": "Netzwerkfehler:",
"login_success_default": "Anmeldung erfolgreich!",
"result_name_label": "Name",
"result_card_label": "Karte",
"result_pickup_label": "Abholpunkt",
"result_points_label": "Treuepunkte",
"connected_relogin": "✅ Verbunden — Erneut anmelden",
"connected_as": "Verbunden als {name}"
}, },
"camera": { "camera": {
"title": "📷 Kamera", "title": "📷 Kamera",
@@ -600,6 +676,8 @@
}, },
"error": { "error": {
"generic": "Fehler", "generic": "Fehler",
"network": "Netzwerkfehler",
"no_api_key": "API-Schluessel in den Einstellungen konfigurieren",
"loading": "Fehler beim Laden des Produkts", "loading": "Fehler beim Laden des Produkts",
"not_found": "Produkt nicht gefunden", "not_found": "Produkt nicht gefunden",
"not_found_manual": "Produkt nicht gefunden. Manuell eingeben.", "not_found_manual": "Produkt nicht gefunden. Manuell eingeben.",
@@ -656,7 +734,9 @@
"colazione": "Frühstück", "colazione": "Frühstück",
"merenda": "Nachmittagssnack", "merenda": "Nachmittagssnack",
"dolce": "Dessert", "dolce": "Dessert",
"succo": "Fruchtsaft" "succo": "Fruchtsaft",
"pranzo": "Mittagessen",
"cena": "Abendessen"
}, },
"scale": { "scale": {
"status_connected": "Waage verbunden", "status_connected": "Waage verbunden",
+86 -6
View File
@@ -169,7 +169,9 @@
"hint": "Scan the barcode, type the product name, or use AI to identify it", "hint": "Scan the barcode, type the product name, or use AI to identify it",
"debug_toggle": "🐛 Debug Log", "debug_toggle": "🐛 Debug Log",
"barcode_acquired": "🔖 Barcode scanned: {code}", "barcode_acquired": "🔖 Barcode scanned: {code}",
"scan_barcode": "🔖 Scan Barcode" "scan_barcode": "🔖 Scan Barcode",
"create_named": "Create {name}",
"new_without_barcode": "New product without barcode"
}, },
"action": { "action": {
"title": "What do you want to do?", "title": "What do you want to do?",
@@ -212,7 +214,11 @@
"title": "Use / Consume", "title": "Use / Consume",
"location_label": "📍 From where?", "location_label": "📍 From where?",
"quantity_label": "How much did you use?", "quantity_label": "How much did you use?",
"change": "change",
"partial_hint": "Or specify the quantity used:", "partial_hint": "Or specify the quantity used:",
"partial_piece_hint": "Did you use only a part?",
"piece": "piece",
"one_whole": "1 whole",
"use_all": "🗑️ Used ALL / Finished", "use_all": "🗑️ Used ALL / Finished",
"submit": "📤 Use this quantity", "submit": "📤 Use this quantity",
"available": "📦 Available:", "available": "📦 Available:",
@@ -273,7 +279,41 @@
"recipes": { "recipes": {
"title": "🍳 Recipes", "title": "🍳 Recipes",
"generate": "✨ Generate new recipe", "generate": "✨ Generate new recipe",
"archive_empty": "No recipes saved. Generate your first recipe!" "archive_empty": "No recipes saved. Generate your first recipe!",
"dialog_title": "🍳 Recipe",
"dialog_desc": "I will generate a healthy recipe using pantry ingredients, prioritizing expiring items.",
"meal_label": "🕐 Which meal?",
"persons_label": "👥 How many people?",
"meal_type_label": "🎯 Meal type",
"opt_fast": "⚡ Quick meal",
"opt_light": "🥗 Light appetite",
"opt_expiry": "⏰ Prioritize expiring items",
"opt_healthy": "💚 Extra healthy",
"opt_opened": "📦 Prioritize opened items",
"opt_zero_waste": "♻️ Zero waste",
"generate_btn": "✨ Generate Recipe",
"loading_msg": "Preparing your recipe...",
"start_cooking": "👨‍🍳 Cooking Mode",
"regenerate": "🔄 Generate another one",
"close_btn": "✅ Close",
"ingredients_title": "🧾 Ingredients",
"steps_title": "👨‍🍳 Steps",
"no_steps": "No steps available",
"generate_error": "Generation error",
"persons_short": "serv.",
"use_ingredient_title": "Use ingredient",
"recipe_qty_label": "Recipe",
"from_where_label": "From where?",
"amount_label": "How much",
"use_amount_btn": "Use this amount",
"use_all_btn": "Use ALL / Finished",
"packs_label": "Packs",
"quantity_in_total": "Quantity in {unit} (total: {total})",
"packs_of_have": "Packs of {size} (you have {count} packs)",
"scale_wait_stable": "Wait 10s of stable weight for auto-fill…",
"ingredient_scaled_toast": "📦 Ingredient deducted from pantry!",
"finished_added_bring_toast": "🛒 Finished product → added to Bring!",
"load_error": "Loading error"
}, },
"shopping": { "shopping": {
"title": "🛒 Shopping List", "title": "🛒 Shopping List",
@@ -333,7 +373,31 @@
"bring_adding": "Adding...", "bring_adding": "Adding...",
"bring_added_one": "1 product added to Bring!", "bring_added_one": "1 product added to Bring!",
"bring_added_many": "{n} products added to Bring!", "bring_added_many": "{n} products added to Bring!",
"bring_skipped": "({n} already in list)" "bring_skipped": "({n} already in list)",
"force_sync": "Force Bring! sync",
"scan_target_label": "You are looking for",
"scan_target_found": "Found! Remove from list",
"bring_add_one": "Add 1 product to Bring!",
"bring_add_many": "Add {n} products to Bring!",
"syncing": "Syncing…",
"sync_done": "Sync completed",
"price_searching": "Searching...",
"search_action": "Search",
"open_action": "Open",
"not_found": "Not found",
"search_price": "Search price",
"tap_to_scan": "Tap to scan",
"tag_title": "Tag",
"remove_title": "Remove",
"found_count": "{found}/{total} products found",
"savings_offers": "· 🏷️ You save €{amount} with offers",
"searching_progress": "Searching {current}/{total}...",
"remove_error": "Removal error",
"suggest_loading": "Analyzing...",
"suggest_error": "Suggestion generation error",
"priority_high": "High",
"priority_medium": "Medium",
"priority_low": "Low"
}, },
"ai": { "ai": {
"title": "🤖 AI Identification", "title": "🤖 AI Identification",
@@ -394,7 +458,8 @@
"timer_expired_tts": "Timer {label} expired!", "timer_expired_tts": "Timer {label} expired!",
"timer_warning_tts": "Heads up! {label}: 10 seconds left!", "timer_warning_tts": "Heads up! {label}: 10 seconds left!",
"recipe_done_tts": "Recipe complete! Enjoy your meal!", "recipe_done_tts": "Recipe complete! Enjoy your meal!",
"expires_chip": "exp. {date}" "expires_chip": "exp. {date}",
"finish": "✅ Finish"
}, },
"settings": { "settings": {
"title": "⚙️ Settings", "title": "⚙️ Settings",
@@ -468,7 +533,18 @@
"ai_prompt_label": "🤖 AI product selection prompt", "ai_prompt_label": "🤖 AI product selection prompt",
"ai_prompt_placeholder": "Instructions for AI when choosing between multiple products...", "ai_prompt_placeholder": "Instructions for AI when choosing between multiple products...",
"ai_prompt_hint": "AI uses this prompt to choose the most appropriate product from results. Leave empty for default behavior.", "ai_prompt_hint": "AI uses this prompt to choose the most appropriate product from results. Leave empty for default behavior.",
"configure_first": "Configure Online Shopping in settings first" "configure_first": "Configure Online Shopping in settings first",
"missing_credentials": "Enter email and password",
"login_in_progress": "Signing in...",
"login_error_prefix": "Error:",
"login_network_error_prefix": "Network error:",
"login_success_default": "Login successful!",
"result_name_label": "Name",
"result_card_label": "Card",
"result_pickup_label": "Pickup point",
"result_points_label": "Loyalty points",
"connected_relogin": "✅ Connected — Sign in again",
"connected_as": "Connected as {name}"
}, },
"camera": { "camera": {
"title": "📷 Camera", "title": "📷 Camera",
@@ -600,6 +676,8 @@
}, },
"error": { "error": {
"generic": "Error", "generic": "Error",
"network": "Network error",
"no_api_key": "Configure the API key in settings",
"loading": "Error loading product", "loading": "Error loading product",
"not_found": "Product not found", "not_found": "Product not found",
"not_found_manual": "Product not found. Enter it manually.", "not_found_manual": "Product not found. Enter it manually.",
@@ -656,7 +734,9 @@
"colazione": "Breakfast", "colazione": "Breakfast",
"merenda": "Snack", "merenda": "Snack",
"dolce": "Dessert", "dolce": "Dessert",
"succo": "Fruit Juice" "succo": "Fruit Juice",
"pranzo": "Lunch",
"cena": "Dinner"
}, },
"scale": { "scale": {
"status_connected": "Scale connected", "status_connected": "Scale connected",
+86 -6
View File
@@ -169,7 +169,9 @@
"hint": "Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo", "hint": "Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo",
"debug_toggle": "🐛 Debug Log", "debug_toggle": "🐛 Debug Log",
"barcode_acquired": "🔖 Barcode acquisito: {code}", "barcode_acquired": "🔖 Barcode acquisito: {code}",
"scan_barcode": "🔖 Scansiona Barcode" "scan_barcode": "🔖 Scansiona Barcode",
"create_named": "Crea {name}",
"new_without_barcode": "Nuovo prodotto senza barcode"
}, },
"action": { "action": {
"title": "Cosa vuoi fare?", "title": "Cosa vuoi fare?",
@@ -212,7 +214,11 @@
"title": "Usa / Consuma", "title": "Usa / Consuma",
"location_label": "📍 Da dove?", "location_label": "📍 Da dove?",
"quantity_label": "Quanto hai usato?", "quantity_label": "Quanto hai usato?",
"change": "cambia",
"partial_hint": "Oppure specifica la quantità usata:", "partial_hint": "Oppure specifica la quantità usata:",
"partial_piece_hint": "Hai usato solo una parte?",
"piece": "pezzo",
"one_whole": "1 intero",
"use_all": "🗑️ Usato TUTTO / Finito", "use_all": "🗑️ Usato TUTTO / Finito",
"submit": "📤 Usa questa quantità", "submit": "📤 Usa questa quantità",
"available": "📦 Disponibile:", "available": "📦 Disponibile:",
@@ -273,7 +279,41 @@
"recipes": { "recipes": {
"title": "🍳 Ricette", "title": "🍳 Ricette",
"generate": "✨ Genera nuova ricetta", "generate": "✨ Genera nuova ricetta",
"archive_empty": "Nessuna ricetta salvata. Genera la tua prima ricetta!" "archive_empty": "Nessuna ricetta salvata. Genera la tua prima ricetta!",
"dialog_title": "🍳 Ricetta",
"dialog_desc": "Genero una ricetta sana con gli ingredienti in dispensa, dando priorità a quelli in scadenza.",
"meal_label": "🕐 Per quale pasto?",
"persons_label": "👥 Quante persone?",
"meal_type_label": "🎯 Tipo di pasto",
"opt_fast": "⚡ Pasto Veloce",
"opt_light": "🥗 Poca Fame",
"opt_expiry": "⏰ Priorità Scadenze",
"opt_healthy": "💚 Extra Salutare",
"opt_opened": "📦 Priorità Cose Aperte",
"opt_zero_waste": "♻️ Zero Sprechi",
"generate_btn": "✨ Genera Ricetta",
"loading_msg": "Sto preparando la ricetta...",
"start_cooking": "👨‍🍳 Modalità Cucina",
"regenerate": "🔄 Generane un'altra",
"close_btn": "✅ Chiudi",
"ingredients_title": "🧾 Ingredienti",
"steps_title": "👨‍🍳 Procedimento",
"no_steps": "Nessun procedimento disponibile",
"generate_error": "Errore nella generazione",
"persons_short": "pers.",
"use_ingredient_title": "Usa ingrediente",
"recipe_qty_label": "Ricetta",
"from_where_label": "Da dove?",
"amount_label": "Quanto",
"use_amount_btn": "Usa questa quantità",
"use_all_btn": "Usa TUTTO / Finito",
"packs_label": "Confezioni",
"quantity_in_total": "Quantità in {unit} (totale: {total})",
"packs_of_have": "Confezioni da {size} (hai {count} conf)",
"scale_wait_stable": "Attendi 10s di stabilità per la compilazione automatica…",
"ingredient_scaled_toast": "📦 Ingrediente scalato dalla dispensa!",
"finished_added_bring_toast": "🛒 Prodotto finito → aggiunto a Bring!",
"load_error": "Errore nel caricamento"
}, },
"shopping": { "shopping": {
"title": "🛒 Lista della Spesa", "title": "🛒 Lista della Spesa",
@@ -333,7 +373,31 @@
"bring_adding": "Aggiunta in corso...", "bring_adding": "Aggiunta in corso...",
"bring_added_one": "1 prodotto aggiunto a Bring!", "bring_added_one": "1 prodotto aggiunto a Bring!",
"bring_added_many": "{n} prodotti aggiunti a Bring!", "bring_added_many": "{n} prodotti aggiunti a Bring!",
"bring_skipped": "({n} già in lista)" "bring_skipped": "({n} già in lista)",
"force_sync": "Forza sincronizzazione Bring!",
"scan_target_label": "Stai cercando",
"scan_target_found": "Trovato! Rimuovi dalla lista",
"bring_add_one": "Aggiungi 1 prodotto a Bring!",
"bring_add_many": "Aggiungi {n} prodotti a Bring!",
"syncing": "Sincronizzazione…",
"sync_done": "Sincronizzazione completata",
"price_searching": "Cerco...",
"search_action": "Ricerca",
"open_action": "Apri",
"not_found": "Non trovato",
"search_price": "Cerca prezzo",
"tap_to_scan": "Tocca per scansionare",
"tag_title": "Tag",
"remove_title": "Rimuovi",
"found_count": "{found}/{total} prodotti trovati",
"savings_offers": "· 🏷️ Risparmi €{amount} con le offerte",
"searching_progress": "Cerco {current}/{total}...",
"remove_error": "Errore nella rimozione",
"suggest_loading": "Analisi in corso...",
"suggest_error": "Errore nella generazione",
"priority_high": "Alta",
"priority_medium": "Media",
"priority_low": "Bassa"
}, },
"ai": { "ai": {
"title": "🤖 Identificazione AI", "title": "🤖 Identificazione AI",
@@ -394,7 +458,8 @@
"timer_expired_tts": "Timer {label} scaduto!", "timer_expired_tts": "Timer {label} scaduto!",
"timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!", "timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!",
"recipe_done_tts": "Ricetta completata! Buon appetito!", "recipe_done_tts": "Ricetta completata! Buon appetito!",
"expires_chip": "scade {date}" "expires_chip": "scade {date}",
"finish": "✅ Fine"
}, },
"settings": { "settings": {
"title": "⚙️ Configurazione", "title": "⚙️ Configurazione",
@@ -468,7 +533,18 @@
"ai_prompt_label": "🤖 Prompt AI selezione prodotto", "ai_prompt_label": "🤖 Prompt AI selezione prodotto",
"ai_prompt_placeholder": "Istruzioni per l'AI quando deve scegliere tra più prodotti...", "ai_prompt_placeholder": "Istruzioni per l'AI quando deve scegliere tra più prodotti...",
"ai_prompt_hint": "L'AI usa questo prompt per scegliere il prodotto più appropriato tra i risultati. Lascia vuoto per il comportamento predefinito.", "ai_prompt_hint": "L'AI usa questo prompt per scegliere il prodotto più appropriato tra i risultati. Lascia vuoto per il comportamento predefinito.",
"configure_first": "Configura prima la Spesa Online nelle impostazioni" "configure_first": "Configura prima la Spesa Online nelle impostazioni",
"missing_credentials": "Inserisci email e password",
"login_in_progress": "Accesso in corso...",
"login_error_prefix": "Errore:",
"login_network_error_prefix": "Errore di rete:",
"login_success_default": "Login effettuato!",
"result_name_label": "Nome",
"result_card_label": "Tessera",
"result_pickup_label": "Punto Ritiro",
"result_points_label": "Punti Fedeltà",
"connected_relogin": "✅ Connesso — Riaccedi",
"connected_as": "Connesso come {name}"
}, },
"camera": { "camera": {
"title": "📷 Fotocamera", "title": "📷 Fotocamera",
@@ -600,6 +676,8 @@
}, },
"error": { "error": {
"generic": "Errore", "generic": "Errore",
"network": "Errore di rete",
"no_api_key": "Configura la chiave API nelle impostazioni",
"loading": "Errore nel caricamento del prodotto", "loading": "Errore nel caricamento del prodotto",
"not_found": "Prodotto non trovato", "not_found": "Prodotto non trovato",
"not_found_manual": "Prodotto non trovato. Inseriscilo manualmente.", "not_found_manual": "Prodotto non trovato. Inseriscilo manualmente.",
@@ -656,7 +734,9 @@
"colazione": "Colazione", "colazione": "Colazione",
"merenda": "Merenda", "merenda": "Merenda",
"dolce": "Dolce", "dolce": "Dolce",
"succo": "Succo di Frutta" "succo": "Succo di Frutta",
"pranzo": "Pranzo",
"cena": "Cena"
}, },
"scale": { "scale": {
"status_connected": "Bilancia connessa", "status_connected": "Bilancia connessa",