852 lines
34 KiB
JavaScript
852 lines
34 KiB
JavaScript
// wwwroot/js/ingredients.js
|
||
|
||
// ======================
|
||
// Globals & Helpers
|
||
// ======================
|
||
let OWNED_SET = new Set();
|
||
let ALL_AGG_INGS = [];
|
||
let AI_CTRL = null;
|
||
const AI_DELAY = 300;
|
||
const SEARCH_STATE = { query: "", mode: null };
|
||
const els = { list: null, search: null };
|
||
// --- Exclusions par recette ---
|
||
const RECIPE_ING_CACHE = new Map(); // id -> { '1': [noms], '2': [...], ... }
|
||
const EXCLUDED_BY_RECIPE = new Map(); // id -> Set(noms normalisés)
|
||
|
||
const GET_ALL_RECIPES_URL = "/HelloFresh/GetAllRecipes";
|
||
const GET_MY_OWNED_URL = "/HelloFresh/GetRecipesOwned?isUserImportant=true";
|
||
|
||
const CORE_USERS = Array.isArray(window.CORE_USERS) ? window.CORE_USERS : ["Mae", "Byakuya"];
|
||
|
||
// État “Ingrédients à avoir”
|
||
let LAST_ING_TO_HAD = null;
|
||
let LAST_PORTIONS = 1;
|
||
let lastNeededSignature = "";
|
||
let prevNotShippedCount = -1;
|
||
const IS_CORE_USER = (window.IS_CORE_USER === true || window.IS_CORE_USER === 'true');
|
||
|
||
const ACCENT_RX = /[\u0300-\u036f]/g;
|
||
const normalizeName = (s) => (s || "").trim().toLowerCase();
|
||
const normalizeText = (s) => {
|
||
let t = (s || "").toLowerCase();
|
||
try { t = t.normalize("NFD").replace(ACCENT_RX, ""); } catch { }
|
||
return t.replace(/\s/g, " ").trim();
|
||
};
|
||
|
||
// Fractions unicode → décimaux
|
||
const VULGAR_FRAC = { "½": "0.5", "¼": "0.25", "¾": "0.75", "⅓": "0.3333", "⅔": "0.6667", "⅛": "0.125", "⅜": "0.375", "⅝": "0.625", "⅞": "0.875" };
|
||
|
||
// Rang unités
|
||
const UNIT_RANK = { "pièce": 6, "piece": 6, "pcs": 6, "pc": 6, "cs": 5, "tbsp": 5, "cc": 4, "tsp": 4, "g": 3, "kg": 3, "mg": 3, "ml": 3, "cl": 3, "l": 3, "": 1 };
|
||
|
||
const nameKey = (s) => (s || "").trim().toLowerCase();
|
||
|
||
// --- Exclusions par recette (ingrédients "principaux" ET "à avoir") ---
|
||
const EXCLUDED_NEEDED_BY_RECIPE = new Map(); // id -> Set(noms normalisés)
|
||
|
||
|
||
function excludedUnionFrom(map) {
|
||
const out = new Set();
|
||
for (const set of map.values()) for (const n of set) out.add(n);
|
||
return out;
|
||
}
|
||
function applyNeededExclusions(items) {
|
||
if (EXCLUDED_NEEDED_BY_RECIPE.size === 0) return items;
|
||
const ex = excludedUnionFrom(EXCLUDED_NEEDED_BY_RECIPE);
|
||
return (items || []).filter(it => !ex.has(nameKey(it?.Name)));
|
||
}
|
||
|
||
// Récupère les noms "à avoir" d’une recette (à partir de l’objet recette du strip)
|
||
function getNeededNamesForRecipeObj(recipe, portions) {
|
||
if (!recipe) return [];
|
||
const det = detectIngredientsToHadProp(recipe);
|
||
const arr = det ? (det.parsed?.[String(portions)] || []) : [];
|
||
return (Array.isArray(arr) ? arr : [])
|
||
.map(el => (el?.Name ?? el?.name ?? "").toString().trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
|
||
(function addExcludeStyle() {
|
||
const id = "hf-exclude-style";
|
||
if (document.getElementById(id)) return;
|
||
const st = document.createElement("style"); st.id = id;
|
||
st.textContent = `
|
||
#recipeStrip .card.card--excluded{ opacity:.45; outline:2px dashed #888; }
|
||
#recipeStrip .card.card--excluded:hover{ opacity:.7; }
|
||
`;
|
||
document.head.appendChild(st);
|
||
})();
|
||
|
||
// Retourne les noms d'ingrédients (par portion 1..4) pour une recette
|
||
async function getIngredientNamesForRecipe(recipeId, portions = 1) {
|
||
const id = String(recipeId);
|
||
if (!RECIPE_ING_CACHE.has(id)) {
|
||
try {
|
||
const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(id)}`);
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
const arr = await res.json();
|
||
const one = Array.isArray(arr) ? arr.find(x => String(x.id) === id) : null;
|
||
const map = {};
|
||
if (one && one.ingredientsJson) {
|
||
try {
|
||
const obj = JSON.parse(one.ingredientsJson);
|
||
for (const k of ["1", "2", "3", "4"]) {
|
||
const list = Array.isArray(obj[k]) ? obj[k] : [];
|
||
map[k] = list.map(el => (el?.Name ?? el?.name ?? "").toString().trim()).filter(Boolean);
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
RECIPE_ING_CACHE.set(id, map);
|
||
} catch (e) {
|
||
console.warn("[ingredients] GetRecipesDetails fail for", id, e);
|
||
RECIPE_ING_CACHE.set(id, {}); // évite re-fetch en boucle
|
||
}
|
||
}
|
||
const cache = RECIPE_ING_CACHE.get(id) || {};
|
||
return (cache[String(portions)] || []).slice();
|
||
}
|
||
|
||
// Union des exclusions (Set global)
|
||
function excludedUnion() {
|
||
const out = new Set();
|
||
for (const set of EXCLUDED_BY_RECIPE.values()) {
|
||
for (const n of set) out.add(n);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Filtre une liste d’items en appliquant les exclusions
|
||
function applyExclusionsTo(items) {
|
||
if (EXCLUDED_BY_RECIPE.size === 0) return items;
|
||
const ex = excludedUnion();
|
||
return (items || []).filter(it => !ex.has(nameKey(it?.Name)));
|
||
}
|
||
|
||
// ========== Normalisation quantités ==========
|
||
function normalizeQuantityText(q) {
|
||
if (!q) return "";
|
||
let s = String(q).toLowerCase();
|
||
s = s.replace(/[½¼¾⅓⅔⅛⅜⅝⅞]/g, m => VULGAR_FRAC[m] || m);
|
||
s = s.replace(/\b(\d+)\s*\/\s*(\d+)\b/g, (_, a, b) => {
|
||
const num = parseFloat(a), den = parseFloat(b);
|
||
return (!den || isNaN(num) || isNaN(den)) ? `${a}/${b}` : (num / den).toString();
|
||
});
|
||
s = s.replace(/,/g, ".").replace(/\s+/g, " ").trim();
|
||
s = s
|
||
.replace(/\b(pieces?)\b/g, "pièce")
|
||
.replace(/\b(pcs?)\b/g, "pièce")
|
||
.replace(/\b(gr|grammes?|grams?)\b/g, "g")
|
||
.replace(/\b(millilitres?|milliliters?|mls?)\b/g, "ml")
|
||
.replace(/\b(litres?|liters?)\b/g, "l");
|
||
s = s
|
||
.replace(/\b(c(?:uill[eè]re)?s?\s*(?:a|à)\s*s(?:oupe)?s?\.?)\b/gi, "cs")
|
||
.replace(/\b(c(?:uill[eè]re)?s?\s*(?:a|à)\s*caf[eé]s?s?\.?)\b/gi, "cc");
|
||
s = s.replace(/[\/]+/g, " ").trim();
|
||
return s;
|
||
}
|
||
function parseQuantity(q) {
|
||
const s = normalizeQuantityText(q);
|
||
const m = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*([a-zéû]+)?$/i);
|
||
if (!m) return { value: NaN, unit: "", raw: s };
|
||
return { value: parseFloat(m[1]), unit: (m[2] || "").trim(), raw: s };
|
||
}
|
||
function quantityScore(q) {
|
||
const { value, unit, raw } = parseQuantity(q);
|
||
const isNum = !Number.isNaN(value);
|
||
const unitRank = UNIT_RANK[unit] ?? (unit ? 2 : 1);
|
||
return { isNum, unitRank, value: isNum ? value : -Infinity, raw };
|
||
}
|
||
function pickBestQuantity(qtys) {
|
||
const arr = Array.from(qtys || []);
|
||
if (arr.length === 0) return "";
|
||
const dedup = Array.from(new Map(arr.map(q => [normalizeQuantityText(q), q])).values());
|
||
dedup.sort((a, b) => {
|
||
const sa = quantityScore(a), sb = quantityScore(b);
|
||
if (sa.isNum !== sb.isNum) return sa.isNum ? -1 : 1;
|
||
if (sa.unitRank !== sb.unitRank) return sb.unitRank - sa.unitRank;
|
||
if (sa.value !== sb.value) return sb.value - sa.value;
|
||
return sa.raw.localeCompare(sb.raw, "fr");
|
||
});
|
||
return normalizeQuantityText(dedup[0]);
|
||
}
|
||
|
||
// ========== Ingrédients utils ==========
|
||
function normalizeItem(it) {
|
||
if (!it || typeof it !== "object") return { Name: "", Quantity: "", Image: "", Owned: false };
|
||
return {
|
||
Name: it.Name ?? it.name ?? "",
|
||
Quantity: it.Quantity ?? it.quantity ?? "",
|
||
Image: it.Image ?? it.image ?? "",
|
||
Owned: Boolean(it.Owned ?? it.owned ?? false),
|
||
};
|
||
}
|
||
function isOwned(name) { return OWNED_SET.has(normalizeName(name)); }
|
||
function mergeDuplicates(items) {
|
||
const map = new Map();
|
||
for (const raw of items) {
|
||
const it = normalizeItem(raw);
|
||
if (!it.Name) continue;
|
||
const key = normalizeName(it.Name);
|
||
const prev = map.get(key);
|
||
if (!prev) {
|
||
map.set(key, { ...it, _qty: new Set(it.Quantity ? [it.Quantity] : []) });
|
||
} else {
|
||
if (!prev.Image && it.Image) prev.Image = it.Image;
|
||
if (it.Quantity) prev._qty.add(it.Quantity);
|
||
prev.Owned = prev.Owned || it.Owned;
|
||
}
|
||
}
|
||
return Array.from(map.values()).map(x => ({ ...x, Quantity: pickBestQuantity(x._qty) }));
|
||
}
|
||
function sortIngredientsForDisplay(items) {
|
||
return items.map(normalizeItem).filter(x => x.Name).sort((a, b) => {
|
||
const ao = isOwned(a.Name) ? 1 : 0, bo = isOwned(b.Name) ? 1 : 0;
|
||
if (ao !== bo) return ao - bo;
|
||
return (a.Name || "").localeCompare((b.Name || ""), "fr", { sensitivity: "base" });
|
||
});
|
||
}
|
||
|
||
// ========== UI cartes ingrédients ==========
|
||
function renderIngredientCard(ingRaw) {
|
||
const ing = normalizeItem(ingRaw);
|
||
const div = document.createElement("div");
|
||
div.className = "card";
|
||
div.dataset.name = ing.Name;
|
||
if (isOwned(ing.Name)) div.classList.add("checked");
|
||
div.innerHTML = `
|
||
<div class="image-container">
|
||
<img src="${ing.Image || ""}" alt="${ing.Name}">
|
||
</div>
|
||
<div class="card-content">
|
||
<h3>${ing.Name || "(ingrédient)"}</h3>
|
||
<div class="footerCard">
|
||
<div class="selection-slot"></div>
|
||
<div class="recipe-info">
|
||
<span class="prep-time">${ing.Quantity || ""}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
let busy = false;
|
||
div.addEventListener("click", async () => {
|
||
if (busy) return; busy = true;
|
||
const name = div.dataset.name, key = normalizeName(name), was = div.classList.contains("checked");
|
||
div.classList.toggle("checked");
|
||
try {
|
||
const res = await fetch("/HelloFresh/ToggleOwnedIngredient", {
|
||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(name),
|
||
});
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
const { status } = await res.json();
|
||
if (status === "added") OWNED_SET.add(key);
|
||
else if (status === "removed") OWNED_SET.delete(key);
|
||
else div.classList.toggle("checked", was);
|
||
const idx = ALL_AGG_INGS.findIndex(x => normalizeName(x.Name) === key);
|
||
if (idx !== -1) ALL_AGG_INGS[idx].Owned = (status === "added");
|
||
} catch (e) {
|
||
console.error("Erreur toggle ingrédient", e);
|
||
div.classList.toggle("checked", was);
|
||
} finally {
|
||
busy = false; reapplyCurrentSearch();
|
||
}
|
||
});
|
||
return div;
|
||
}
|
||
function renderItems(items) {
|
||
if (!els.list) return;
|
||
els.list.innerHTML = "";
|
||
items.forEach(ing => els.list.appendChild(renderIngredientCard(ing)));
|
||
}
|
||
|
||
// ========== Recherche ==========
|
||
async function applyAISearch(q) {
|
||
SEARCH_STATE.query = q; SEARCH_STATE.mode = q ? "ai" : null;
|
||
if (!q?.trim()) { renderItems(sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]))); return; }
|
||
if (els.list) els.list.innerHTML = "<em>Recherche IA…</em>";
|
||
try {
|
||
if (AI_CTRL) AI_CTRL.abort(); AI_CTRL = new AbortController();
|
||
const res = await fetch("/HelloFresh/SmartSearch?debug=true", {
|
||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: q }), signal: AI_CTRL.signal
|
||
});
|
||
const raw = await res.clone().text();
|
||
if (!res.ok) { console.warn("AI SmartSearch HTTP", res.status, raw); applyLiveFilter(q); return; }
|
||
const data = JSON.parse(raw);
|
||
const items = Array.isArray(data?.items) ? data.items : [];
|
||
if (!items.length) { applyLiveFilter(q); return; }
|
||
renderItems(sortIngredientsForDisplay(mergeDuplicates(items)));
|
||
} catch (err) {
|
||
if (err.name === "AbortError") console.log("[AI] annulée");
|
||
else { console.error("AI SmartSearch error:", err); applyLiveFilter(q); }
|
||
} finally { AI_CTRL = null; }
|
||
}
|
||
function applyLiveFilter(q) {
|
||
SEARCH_STATE.query = String(q ?? ""); SEARCH_STATE.mode = q ? "live" : null;
|
||
const nq = normalizeText(q);
|
||
if (!nq) { renderItems(sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]))); return; }
|
||
const filtered = ALL_AGG_INGS.filter(x => normalizeText(x.Name).includes(nq));
|
||
const list = sortIngredientsForDisplay(mergeDuplicates(applyExclusionsTo(filtered)));
|
||
renderItems(list);
|
||
}
|
||
function reapplyCurrentSearch() {
|
||
const q = SEARCH_STATE.query || "";
|
||
if (!q) {
|
||
const base = sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]));
|
||
renderItems(applyExclusionsTo(base));
|
||
return;
|
||
}
|
||
if (SEARCH_STATE.mode === "ai") applyAISearch(q); else applyLiveFilter(q);
|
||
}
|
||
|
||
// ========== Chargement liste principale ==========
|
||
async function loadAndRenderIngredients() {
|
||
if (!els.list) return;
|
||
els.list.innerHTML = "Chargement…";
|
||
|
||
try {
|
||
if (IS_CORE_USER) {
|
||
// 🔒 Vue partagée: ingrédients = uniquement Mae/Byakuya
|
||
const url = `/HelloFresh/GetRecipesOwnedByUsers?names=${encodeURIComponent(CORE_USERS.join(","))}`;
|
||
const res = await fetch(url, { credentials: "same-origin" });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const rows = await res.json();
|
||
window.allRecipes = Array.isArray(rows) ? rows : [];
|
||
|
||
const items = await buildAggregatedIngredientsFromRows(window.allRecipes);
|
||
ALL_AGG_INGS = items;
|
||
|
||
const list = sortIngredientsForDisplay(applyExclusionsTo(items));
|
||
els.list.innerHTML = "";
|
||
renderItems(list.length ? list : []);
|
||
if (!list.length) els.list.innerHTML = "<em>Aucun ingrédient</em>";
|
||
return;
|
||
}
|
||
|
||
// Utilisateur "tiers" : ingrédients = SES recettes (déjà en place)
|
||
const mineAgg = await buildMyAggregatedIngredients();
|
||
ALL_AGG_INGS = mineAgg;
|
||
const list = sortIngredientsForDisplay(applyExclusionsTo(mineAgg));
|
||
els.list.innerHTML = "";
|
||
renderItems(list.length ? list : []);
|
||
if (!list.length) els.list.innerHTML = "<em>Aucun ingrédient</em>";
|
||
} catch (e) {
|
||
console.error("[ingredients] loadAndRenderIngredients exception", e);
|
||
els.list.innerHTML = "Erreur chargement ingrédients";
|
||
}
|
||
}
|
||
|
||
|
||
// ========== Not shipped (DOM) + BDD ==========
|
||
function getNotShippedIngredientsFromDOM(root = document) {
|
||
const nodes = Array.from(root.querySelectorAll(".ingredient-item-not-shipped"));
|
||
return nodes.map(n => {
|
||
const name = (n.querySelector('[data-test="ingredient-name"], .ingredient-name, .name, h3, span')?.textContent || n.getAttribute("data-name") || n.querySelector("img")?.alt || "").trim();
|
||
const qty = (n.querySelector('[data-test="ingredient-quantity"], .ingredient-amount, .quantity, .qty')?.textContent || n.getAttribute("data-quantity") || "").trim();
|
||
const img = (n.querySelector("img")?.src || n.getAttribute("data-img") || "").trim();
|
||
return normalizeItem({ Name: name, Quantity: qty, Image: img, Owned: isOwned(name) });
|
||
}).filter(x => x.Name);
|
||
}
|
||
// Détecte la propriété "ingredients à avoir" dans un objet recette,
|
||
// même si le nom varie (ingredientsToHad / IngredientsToHad / pantry / etc.)
|
||
function detectIngredientsToHadProp(recipe) {
|
||
if (!recipe || typeof recipe !== "object") return null;
|
||
|
||
// Candidats fréquents (essayés d'abord)
|
||
const CANDIDATES = [
|
||
"ingredientsToHad", "IngredientsToHad", "ingredients_to_had",
|
||
"ingredientsToHaveAtHome", "ingredientsToHave", "toHaveAtHome",
|
||
"pantry", "pantryItems", "pantry_ingredients", "ingredientsPantry"
|
||
];
|
||
|
||
for (const key of CANDIDATES) {
|
||
if (key in recipe && recipe[key]) {
|
||
try {
|
||
const val = recipe[key];
|
||
const obj = (typeof val === "string") ? JSON.parse(val) : val;
|
||
if (obj && typeof obj === "object" &&
|
||
(Array.isArray(obj["1"]) || Array.isArray(obj["2"]) || Array.isArray(obj["3"]) || Array.isArray(obj["4"]))) {
|
||
return { prop: key, parsed: obj };
|
||
}
|
||
} catch { }
|
||
}
|
||
}
|
||
|
||
// Heuristique : scanne toutes les props à la recherche d’un objet 1..4 -> array d’objets Name/Quantity
|
||
for (const [key, val] of Object.entries(recipe)) {
|
||
if (!val) continue;
|
||
try {
|
||
const obj = (typeof val === "string") ? JSON.parse(val) : val;
|
||
if (obj && typeof obj === "object") {
|
||
const arr = obj["1"] || obj["2"] || obj["3"] || obj["4"];
|
||
if (Array.isArray(arr) && arr.length) {
|
||
const it = arr[0] || {};
|
||
if (typeof it === "object" && ("Name" in it || "name" in it)) {
|
||
return { prop: key, parsed: obj };
|
||
}
|
||
}
|
||
}
|
||
} catch { }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Lit une recette en tolérant la casse / formats
|
||
function getIngredientsToHadFromRecipe(r) {
|
||
const det = detectIngredientsToHadProp(r);
|
||
if (!det) return [];
|
||
const pRaw = r?.portions ?? r?.Portions ?? 0;
|
||
const portions = clampQty(pRaw) + 1; // 1..4
|
||
const list = Array.isArray(det.parsed[portions]) ? det.parsed[portions] : [];
|
||
return list.map(normalizeItem);
|
||
}
|
||
|
||
|
||
function collectIngredientsToHadFromAllRecipes() {
|
||
const res = [];
|
||
const all = Array.isArray(window.allRecipes) ? window.allRecipes : [];
|
||
for (const r of all) res.push(...getIngredientsToHadFromRecipe(r));
|
||
return res;
|
||
}
|
||
|
||
function computeSignature(items) {
|
||
const merged = mergeDuplicates(items || []);
|
||
return JSON.stringify(merged.map(i => ({ n: normalizeName(i.Name), q: normalizeQuantityText(i.Quantity), img: i.Image || "" })));
|
||
}
|
||
|
||
function renderNeededList(itemsArr) {
|
||
const container = document.getElementById("neededIngredients");
|
||
if (!container) return;
|
||
const merged = mergeDuplicates(itemsArr || []);
|
||
const sig = computeSignature(merged);
|
||
if (sig === lastNeededSignature) return;
|
||
lastNeededSignature = sig;
|
||
container.innerHTML = "";
|
||
for (const ing of merged) {
|
||
const li = document.createElement("li");
|
||
li.className = "card needed";
|
||
li.innerHTML = `
|
||
<div class="image-container"><img src="${ing.Image || ""}" alt="${ing.Name}"></div>
|
||
<div class="card-content">
|
||
<h3>${ing.Name || "(ingrédient)"}</h3>
|
||
<div class="footerCard"><div class="recipe-info"></div></div>
|
||
</div>`;
|
||
container.appendChild(li);
|
||
}
|
||
}
|
||
function updateNeededList(trigger = "manual") {
|
||
const container = document.getElementById("neededIngredients");
|
||
if (!container) {
|
||
console.debug("[needed]", trigger, "→ container #neededIngredients introuvable (on réessaiera)");
|
||
return;
|
||
}
|
||
console.debug("[needed]", trigger, "→ container OK");
|
||
|
||
// 1) DOM (non livrés)
|
||
const domItems = getNotShippedIngredientsFromDOM();
|
||
if (domItems.length) {
|
||
const filtered = applyNeededExclusions(domItems);
|
||
renderNeededList(filtered);
|
||
return;
|
||
}
|
||
|
||
// 2) Agrégation de TOUTES les recettes
|
||
const aggregated = collectIngredientsToHadFromAllRecipes();
|
||
if (aggregated.length) {
|
||
const filtered = applyNeededExclusions(aggregated);
|
||
renderNeededList(filtered);
|
||
return;
|
||
}
|
||
|
||
// 3) Dernière recette vue (fallback)
|
||
if (LAST_ING_TO_HAD) {
|
||
try {
|
||
const parsed = JSON.parse(LAST_ING_TO_HAD || "{}");
|
||
const list = Array.isArray(parsed[LAST_PORTIONS]) ? parsed[LAST_PORTIONS] : [];
|
||
const filtered = applyNeededExclusions(list);
|
||
if (filtered.length) {
|
||
renderNeededList(filtered);
|
||
return;
|
||
}
|
||
} catch (e) { /* ... */ }
|
||
}
|
||
|
||
|
||
// 4) Rien
|
||
const emptySig = "EMPTY";
|
||
if (lastNeededSignature !== emptySig) {
|
||
lastNeededSignature = emptySig;
|
||
container.innerHTML = "<p>Aucun ingrédient à afficher.</p>";
|
||
}
|
||
console.warn("[needed]", trigger, "→ aucun item");
|
||
}
|
||
|
||
// Scan DOM non-livrés (anti-boucle)
|
||
function scanNotShippedAndUpdate() {
|
||
const count = document.querySelectorAll(".ingredient-item-not-shipped").length;
|
||
if (count !== prevNotShippedCount) { prevNotShippedCount = count; updateNeededList(); }
|
||
}
|
||
|
||
// ========== Recettes (strip) ==========
|
||
const ownedMap = new Map();
|
||
const MAX_PORTIONS = 14;
|
||
const clampQty = n => Math.max(0, Math.min(3, Number(n) || 0));
|
||
const truncate = (s, m) => (s && s.length > m ? s.slice(0, m) + "…" : (s ?? ""));
|
||
|
||
function setSelectedUI(card, selected) {
|
||
const slot = card?.querySelector?.(".selection-slot"); if (!slot) return;
|
||
if (selected) {
|
||
slot.classList.add("active");
|
||
if (!slot.querySelector(".selection-indicator")) {
|
||
slot.innerHTML = `<div class="selection-indicator"><span class="selection-text">Sélectionné</span></div>`;
|
||
}
|
||
} else { slot.classList.remove("active"); slot.innerHTML = ""; }
|
||
}
|
||
function applySelectionUIById(id) {
|
||
const card = document.getElementById(String(id));
|
||
const selected = (ownedMap.get(String(id)) || 0) > 0;
|
||
setSelectedUI(card, selected);
|
||
}
|
||
|
||
function buildLabels(tagsStr) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "label-container";
|
||
const tags = (tagsStr || "").split(/[,•]/).map(t => t.trim()).filter(Boolean).filter(t => t !== "•" && t !== "\u2022");
|
||
tags.forEach(tag => {
|
||
const span = document.createElement("span");
|
||
span.className = "label-badge";
|
||
const c = (window.labelColors || {})[tag];
|
||
span.style.backgroundColor = c?.bg ?? "#eee";
|
||
span.style.color = c?.color ?? "#333";
|
||
span.textContent = tag;
|
||
wrap.appendChild(span);
|
||
});
|
||
return wrap;
|
||
}
|
||
|
||
function buildRecipeCardList(recipe) {
|
||
const card = document.createElement("div");
|
||
card.className = "card";
|
||
card.id = recipe.id ?? recipe.Id;
|
||
card.innerHTML = `
|
||
<div class="image-container"><img src="${recipe.image}" alt="${truncate(recipe.name, 10)}"></div>
|
||
<div class="card-content">
|
||
<h3>${recipe.name}</h3>
|
||
<div class="footerCard"><div class="recipe-info"><span class="prep-time">${recipe.tempsDePreparation}min</span></div></div>
|
||
</div>
|
||
`.replace(/\${/g, "${");
|
||
card.addEventListener("click", async (e) => {
|
||
if (e.ctrlKey) return; // laisse le Ctrl+clic tranquille
|
||
|
||
const id = recipe.id ?? recipe.Id;
|
||
const portions = clampQty(recipe.portions ?? recipe.Portions ?? 0) + 1; // 1..4
|
||
|
||
if (EXCLUDED_BY_RECIPE.has(String(id))) {
|
||
// ré-afficher
|
||
EXCLUDED_BY_RECIPE.delete(String(id));
|
||
EXCLUDED_NEEDED_BY_RECIPE.delete(String(id)); // 👈 aussi pour "à avoir"
|
||
card.classList.remove("card--excluded");
|
||
} else {
|
||
// cacher
|
||
const namesMain = await getIngredientNamesForRecipe(id, portions); // déjà en place chez toi
|
||
EXCLUDED_BY_RECIPE.set(String(id), new Set(namesMain.map(nameKey)));
|
||
|
||
const namesNeeded = getNeededNamesForRecipeObj(recipe, portions); // 👈 "à avoir" depuis l’objet recette
|
||
EXCLUDED_NEEDED_BY_RECIPE.set(String(id), new Set(namesNeeded.map(nameKey)));
|
||
|
||
card.classList.add("card--excluded");
|
||
}
|
||
|
||
// Re-rendre les deux panneaux
|
||
reapplyCurrentSearch(); // liste principale
|
||
updateNeededList("exclude-toggle"); // “à avoir”
|
||
});
|
||
|
||
|
||
if (IS_CORE_USER) {
|
||
const usersVal = recipe.Users ?? recipe.users ?? recipe.User ?? recipe.user ?? [];
|
||
const usersArr = Array.isArray(usersVal) ? usersVal.filter(Boolean) : String(usersVal || "").split(/[,•]/).map(s => s.trim()).filter(Boolean);
|
||
if (usersArr.length) {
|
||
const img = card.querySelector(".image-container");
|
||
const wrap = document.createElement("div"); wrap.className = "label-container";
|
||
usersArr.forEach(u => { const span = document.createElement("span"); span.className = "label-badge user-badge"; span.textContent = u; wrap.appendChild(span); });
|
||
img?.appendChild(wrap);
|
||
}
|
||
}
|
||
|
||
// Mémorise la dernière recette pour un fallback, via détection auto
|
||
const det = detectIngredientsToHadProp(recipe);
|
||
const portions = clampQty(recipe.portions ?? recipe.Portions ?? 0) + 1;
|
||
LAST_PORTIONS = portions;
|
||
LAST_ING_TO_HAD = det ? JSON.stringify(det.parsed) : null;
|
||
|
||
console.debug("[needed] buildRecipe detected prop:", det?.prop || "(none)", "portions:", portions);
|
||
updateNeededList("buildRecipe");
|
||
|
||
|
||
return card;
|
||
}
|
||
|
||
async function loadAllRecipesHorizontal() {
|
||
const strip = document.getElementById("recipeStrip");
|
||
if (!strip) return;
|
||
|
||
// === Vue "partagée" (Mae/Byakuya) : ne montrer QUE leurs recettes ===
|
||
if (IS_CORE_USER) {
|
||
try {
|
||
const url = `/HelloFresh/GetRecipesOwnedByUsers?names=${encodeURIComponent(CORE_USERS.join(","))}`;
|
||
const res = await fetch(url, { credentials: "same-origin" });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const onlyCore = await res.json();
|
||
window.allRecipes = Array.isArray(onlyCore) ? onlyCore : [];
|
||
|
||
strip.innerHTML = "";
|
||
if (!window.allRecipes.length) {
|
||
strip.innerHTML = '<p class="placeholder">Aucune recette (Mae/Byakuya).</p>';
|
||
updateNeededList();
|
||
return;
|
||
}
|
||
|
||
window.allRecipes.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" }));
|
||
window.allRecipes.forEach(r => strip.appendChild(buildRecipeCardList(r)));
|
||
updateNeededList("afterCoreRecipes");
|
||
} catch (e) {
|
||
console.error("loadAllRecipesHorizontal (core) error", e);
|
||
strip.innerHTML = '<p class="placeholder">Erreur de chargement.</p>';
|
||
updateNeededList();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// === Utilisateur "tiers" : uniquement SES recettes ===
|
||
try {
|
||
const res = await fetch(GET_MY_OWNED_URL, { credentials: "same-origin" });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const mine = await res.json();
|
||
window.allRecipes = Array.isArray(mine) ? mine : [];
|
||
|
||
strip.innerHTML = "";
|
||
if (!window.allRecipes.length) {
|
||
strip.innerHTML = '<p class="placeholder">Vous n’avez pas encore de recettes sélectionnées.</p>';
|
||
updateNeededList();
|
||
return;
|
||
}
|
||
|
||
window.allRecipes.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" }));
|
||
window.allRecipes.forEach(r => strip.appendChild(buildRecipeCardList(r)));
|
||
updateNeededList("afterGetMyRecipes");
|
||
} catch (e) {
|
||
console.error("loadAllRecipesHorizontal (mine) error", e);
|
||
strip.innerHTML = '<p class="placeholder">Erreur de chargement.</p>';
|
||
updateNeededList();
|
||
}
|
||
}
|
||
|
||
async function refreshOwnedSet() {
|
||
try {
|
||
const r = await fetch("/HelloFresh/GetOwnedIngredients");
|
||
if (r.ok) {
|
||
const names = await r.json();
|
||
OWNED_SET = new Set((names || []).map(normalizeName));
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function buildAggregatedIngredientsFromRows(rows) {
|
||
const ids = (rows || []).map(r => r.id);
|
||
const details = await fetchDetailsBatched(ids);
|
||
const byId = new Map(details.map(d => [String(d.id), d]));
|
||
await refreshOwnedSet();
|
||
|
||
const rawItems = [];
|
||
for (const r of (rows || [])) {
|
||
const det = byId.get(String(r.id));
|
||
if (!det || !det.ingredientsJson) continue;
|
||
|
||
let portions = Number(r.portions || 1);
|
||
if (!Number.isFinite(portions) || portions < 1) portions = 1;
|
||
|
||
const base = Math.min(4, Math.max(1, portions));
|
||
const scale = (portions <= 4) ? 1 : (portions / 4);
|
||
|
||
let obj; try { obj = JSON.parse(det.ingredientsJson); } catch { obj = null; }
|
||
const arr = (obj && Array.isArray(obj[String(base)])) ? obj[String(base)] : [];
|
||
for (const el of arr) {
|
||
const it = normalizeItem(el);
|
||
if (!it.Name) continue;
|
||
|
||
const { value, unit } = parseQuantity(it.Quantity);
|
||
if (!Number.isNaN(value) && scale !== 1) {
|
||
const scaled = value * scale;
|
||
it.Quantity = unit ? `${fmtNumber(scaled)} ${unit}` : fmtNumber(scaled);
|
||
}
|
||
it.Owned = isOwned(it.Name);
|
||
rawItems.push(it);
|
||
}
|
||
}
|
||
return aggregateRawItems(rawItems);
|
||
}
|
||
|
||
|
||
function fmtNumber(n) {
|
||
const i = Math.round(n);
|
||
return (Math.abs(n - i) < 1e-9) ? String(i) : String(Number(n.toFixed(2))).replace(/\.?0+$/, '');
|
||
}
|
||
|
||
function aggregateRawItems(rawItems) {
|
||
// Somme numérique par (name, unit) + “meilleure” quantité textuelle sinon
|
||
const numMap = new Map(); // key: name##unit -> {sum, unit, item}
|
||
const textMap = new Map(); // key: name -> {set:Set(qty), item}
|
||
|
||
for (const raw of rawItems) {
|
||
const it = normalizeItem(raw);
|
||
if (!it.Name) continue;
|
||
const keyName = normalizeName(it.Name);
|
||
const q = it.Quantity || "";
|
||
const { value, unit } = parseQuantity(q);
|
||
|
||
if (!Number.isNaN(value)) {
|
||
const k = `${keyName}##${unit || ""}`;
|
||
const prev = numMap.get(k);
|
||
if (!prev) {
|
||
numMap.set(k, { sum: value, unit: unit || "", item: { ...it } });
|
||
} else {
|
||
prev.sum += value;
|
||
// garder une image si absente
|
||
if (!prev.item.Image && it.Image) prev.item.Image = it.Image;
|
||
prev.item.Owned = prev.item.Owned || it.Owned;
|
||
}
|
||
} else {
|
||
const p = textMap.get(keyName);
|
||
if (!p) {
|
||
const s = new Set(); if (q) s.add(q);
|
||
textMap.set(keyName, { set: s, item: { ...it } });
|
||
} else {
|
||
if (q) p.set.add(q);
|
||
if (!p.item.Image && it.Image) p.item.Image = it.Image;
|
||
p.item.Owned = p.item.Owned || it.Owned;
|
||
}
|
||
}
|
||
}
|
||
|
||
const out = [];
|
||
|
||
// numérique → format “val unit”
|
||
for (const { sum, unit, item } of numMap.values()) {
|
||
const qty = unit ? `${fmtNumber(sum)} ${unit}` : fmtNumber(sum);
|
||
out.push({ ...item, Quantity: qty });
|
||
}
|
||
|
||
// textuel → pickBestQuantity
|
||
for (const { set, item } of textMap.values()) {
|
||
const qty = pickBestQuantity(Array.from(set));
|
||
out.push({ ...item, Quantity: qty });
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
async function fetchDetailsBatched(ids, batchSize = 120) {
|
||
const all = [];
|
||
for (let i = 0; i < ids.length; i += batchSize) {
|
||
const part = ids.slice(i, i + batchSize);
|
||
const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(part.join(','))}`);
|
||
if (res.ok) all.push(...await res.json());
|
||
}
|
||
return all;
|
||
}
|
||
|
||
async function buildMyAggregatedIngredients() {
|
||
// 1) mes recettes + portions
|
||
const r = await fetch(GET_MY_OWNED_URL, { credentials: "same-origin" });
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
const mine = await r.json(); // [{id, portions, ...}]
|
||
|
||
if (!Array.isArray(mine) || mine.length === 0) return [];
|
||
|
||
// 2) détails (ingrédients par 1..4)
|
||
const ids = mine.map(x => x.id);
|
||
const details = await fetchDetailsBatched(ids);
|
||
const byId = new Map(details.map(d => [String(d.id), d]));
|
||
|
||
// 3) Owned flags (facultatif, pour tri visuel)
|
||
await refreshOwnedSet();
|
||
|
||
// 4) construire la liste brute + mise à l’échelle des quantités (>4 portions)
|
||
const rawItems = [];
|
||
for (const rcp of mine) {
|
||
const id = String(rcp.id);
|
||
const det = byId.get(id);
|
||
if (!det || !det.ingredientsJson) continue;
|
||
|
||
let portions = Number(rcp.portions || 1);
|
||
if (!Number.isFinite(portions) || portions < 1) portions = 1;
|
||
|
||
const base = Math.min(4, Math.max(1, portions));
|
||
const scale = (portions <= 4) ? 1 : (portions / 4);
|
||
|
||
let obj;
|
||
try { obj = JSON.parse(det.ingredientsJson); } catch { obj = null; }
|
||
const arr = (obj && Array.isArray(obj[String(base)])) ? obj[String(base)] : [];
|
||
for (const el of arr) {
|
||
const it = normalizeItem(el);
|
||
if (!it.Name) continue;
|
||
|
||
// scale quantité si numérique
|
||
const { value, unit } = parseQuantity(it.Quantity);
|
||
if (!Number.isNaN(value) && scale !== 1) {
|
||
const scaled = value * scale;
|
||
it.Quantity = unit ? `${fmtNumber(scaled)} ${unit}` : fmtNumber(scaled);
|
||
}
|
||
|
||
it.Owned = isOwned(it.Name);
|
||
rawItems.push(it);
|
||
}
|
||
}
|
||
|
||
return aggregateRawItems(rawItems);
|
||
}
|
||
|
||
|
||
// ======================
|
||
// Init
|
||
// ======================
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
els.list = document.getElementById("ingredientsList") || null;
|
||
els.search = document.getElementById("hfSearch") || null;
|
||
|
||
document.querySelectorAll(".dropdown-toggle").forEach(el => el.addEventListener("click", (e) => e.preventDefault()));
|
||
|
||
if (els.search) {
|
||
let aiTimer;
|
||
els.search.addEventListener("input", (e) => { clearTimeout(aiTimer); const val = e.target.value || ""; aiTimer = setTimeout(() => applyAISearch(val), AI_DELAY); });
|
||
els.search.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); clearTimeout(aiTimer); applyAISearch(els.search.value || ""); } });
|
||
} else {
|
||
console.error("[ingredients] #hfSearch introuvable dans le DOM");
|
||
}
|
||
|
||
loadAndRenderIngredients();
|
||
loadAllRecipesHorizontal();
|
||
|
||
// Premier rendu (même si le UL n'est pas encore dans le DOM, on loggue)
|
||
updateNeededList("dom-ready");
|
||
|
||
// Petits réessaies pour les pages qui injectent tardivement le UL / les cartes
|
||
setTimeout(() => updateNeededList("post-300ms"), 300);
|
||
setTimeout(() => updateNeededList("post-1200ms"), 1200);
|
||
const mo = new MutationObserver(() => {
|
||
// Throttle : on regroupe les mutations
|
||
if (typeof window !== "undefined") {
|
||
if (mo._scheduled) return;
|
||
mo._scheduled = true;
|
||
setTimeout(() => {
|
||
mo._scheduled = false;
|
||
updateNeededList("mutation");
|
||
}, 120);
|
||
} else {
|
||
updateNeededList("mutation");
|
||
}
|
||
});
|
||
mo.observe(document.body, { childList: true, subtree: true });
|
||
|
||
});
|