/*********************** * STATE ***********************/ let currentPage = 1; let lastPageGlobal = 1; const countPerPage = 12; let currentSearchTerm = ''; let CURRENT_CLIENT_LIST = null; // liste filtrée paginée côté client let ALL_RECIPES_CACHE = null; // toutes les recettes (toutes pages) // Filtres : édition (dans la card) vs appliqués (vraiment utilisés) let draftFilters = { ingredients: [], tags: [], timeMin: null, timeMax: null }; let appliedFilters = { ingredients: [], tags: [], timeMin: null, timeMax: null }; // Map label affiché par valeur normalisée (chips) const labelMaps = { ingredients: {}, tags: {} }; // idRecette -> portions (0..3) const ownedMap = new Map(); const MAX_PER_RECIPE = null; // null = illimité par recette const MAX_TOTAL = null; // null = illimité au total const clampQty = (n) => { const v = Number(n) || 0; if (v < 0) return 0; // si bornes définies, on clamp ; sinon on laisse passer if (Number.isFinite(MAX_PER_RECIPE)) return Math.min(MAX_PER_RECIPE, v); return v; }; /*********************** * UTILS ***********************/ const truncate = (s, m) => (s && s.length > m ? s.slice(0, m) + '…' : (s ?? '')); const normalize = (str) => (str ?? '').toString().normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim(); const splitTags = (str) => (str || '').split(/[,•]/).map(t => t.trim()).filter(Boolean).filter(t => t !== '•'); const esc = (s) => { const d = document.createElement('div'); d.textContent = (s ?? ''); return d.innerHTML; }; function capFirst(s) { if (s == null) return ''; s = s.toString().trim(); if (!s) return ''; const f = s[0].toLocaleUpperCase('fr-FR'); return f + s.slice(1); } /*********************** * COLORS for tags (persistées localStorage) ***********************/ const DISTINCT_COLORS = [ { bg: "#e41a1c", color: "#fff" }, { bg: "#377eb8", color: "#fff" }, { bg: "#4daf4a", color: "#fff" }, { bg: "#984ea3", color: "#fff" }, { bg: "#ff7f00", color: "#fff" }, { bg: "#ffff33", color: "#333" }, { bg: "#a65628", color: "#fff" }, { bg: "#f781bf", color: "#333" }, { bg: "#999999", color: "#fff" }, { bg: "#1f77b4", color: "#fff" }, { bg: "#ff7f0e", color: "#fff" }, { bg: "#2ca02c", color: "#fff" }, { bg: "#d62728", color: "#fff" }, { bg: "#9467bd", color: "#fff" }, { bg: "#8c564b", color: "#fff" }, { bg: "#e377c2", color: "#fff" }, { bg: "#7f7f7f", color: "#fff" }, { bg: "#bcbd22", color: "#333" }, { bg: "#17becf", color: "#fff" } ]; const LS_KEY = "hf_label_colors_v2"; const loadColorMap = () => { try { return JSON.parse(localStorage.getItem(LS_KEY) || "{}"); } catch { return {}; } }; const saveColorMap = (map) => localStorage.setItem(LS_KEY, JSON.stringify(map)); function getOrAssignLabelColor(tagRaw) { const tag = normalize(tagRaw); const map = loadColorMap(); if (!map[tag]) { const used = Object.values(map).map(c => c.bg); let color = DISTINCT_COLORS.find(c => !used.includes(c.bg)) || DISTINCT_COLORS[Object.keys(map).length % DISTINCT_COLORS.length]; map[tag] = color; saveColorMap(map); } return map[tag]; } function buildLabels(tagsStr) { const wrap = document.createElement("div"); wrap.className = "label-container"; const tags = splitTags(tagsStr); tags.forEach(tag => { const span = document.createElement("span"); span.className = "label-badge"; const c = getOrAssignLabelColor(tag); span.style.backgroundColor = c.bg; span.style.color = c.color; span.textContent = tag; wrap.appendChild(span); }); return wrap; } /*********************** * UI helpers ***********************/ function refreshSelectedBorders() { const grid = document.getElementById('recipeGrid'); if (!grid) return; grid.querySelectorAll('.card').forEach(card => { const id = String(card.id); const qty = clampQty(ownedMap.get(id) || 0); card.classList.toggle('card--selected', qty > 0); }); } function renderPagination(curr, last) { const pageInfo = document.getElementById('pageInfo'); const prevBtn = document.getElementById('prevPage'); const nextBtn = document.getElementById('nextPage'); if (pageInfo) pageInfo.textContent = `${curr}/${last}`; if (prevBtn) prevBtn.disabled = curr <= 1; if (nextBtn) nextBtn.disabled = curr >= last; prevBtn && (prevBtn.onclick = () => { if (currentPage <= 1) return; currentPage--; (CURRENT_CLIENT_LIST ? renderClientPage() : loadRecipes(currentPage)); }); nextBtn && (nextBtn.onclick = () => { if (currentPage >= lastPageGlobal) return; currentPage++; (CURRENT_CLIENT_LIST ? renderClientPage() : loadRecipes(currentPage)); }); } /*********************** * Owned section toggle ***********************/ function ownedWrap() { return document.getElementById('recipeOwnedWrap'); } function ownedHeader() { return document.getElementById('ownedSection'); } function ownedBtn() { return ownedHeader()?.querySelector('.section-chip'); } function setOwnedToggleUI(open) { const btn = ownedBtn(); const t = btn?.querySelector('.collapse-text'); const i = btn?.querySelector('.collapse-icon'); t && (t.textContent = open ? 'Cacher recettes choisies' : 'Afficher recettes choisies'); i && (i.textContent = open ? '▲' : '▼'); } function openOwnedIfClosed() { const wrap = ownedWrap(), header = ownedHeader(); if (!wrap || !header) return; if (!wrap.classList.contains('open')) { wrap.classList.add('open'); wrap.setAttribute('aria-hidden', 'false'); header.classList.add('open'); setOwnedToggleUI(true); } } function closeOwnedIfOpen() { const wrap = ownedWrap(), header = ownedHeader(); if (!wrap || !header) return; if (wrap.classList.contains('open')) { wrap.classList.remove('open'); wrap.setAttribute('aria-hidden', 'true'); header.classList.remove('open'); setOwnedToggleUI(false); } } (function initOwnedToggle() { const header = document.getElementById('ownedSection'); const btn = header?.querySelector('.section-chip'); const wrap = document.getElementById('recipeOwnedWrap'); if (!header || !btn || !wrap) return; btn.addEventListener('click', async () => { const open = wrap.classList.contains('open'); if (open) { wrap.classList.remove('open'); wrap.setAttribute('aria-hidden', 'true'); setOwnedToggleUI(false); return; } await loadRecipesOwned({ onEmpty: 'placeholder' }); wrap.classList.add('open'); wrap.setAttribute('aria-hidden', 'false'); setOwnedToggleUI(true); }); })(); /*********************** * API (save/remove/clear/archive) ***********************/ async function saveRecipe(id) { try { const hadAny = ownedRecipesCount() > 0; const r = await fetch('/HelloFresh/SaveRecipe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(String(id)) }); if (!r.ok) throw new Error(await r.text()); const { qty = 1 } = await r.json(); ownedMap.set(String(id), clampQty(qty)); applySelectionUIById(id); await loadRecipesOwned({ onEmpty: 'placeholder' }); updateOwnedCountUI(); if (!hadAny && ownedRecipesCount() > 0) openOwnedIfClosed(); } catch (e) { console.error('saveRecipe error', e); } } async function removeRecette(id) { try { const r = await fetch('/HelloFresh/DeleteRecipesOwned', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(String(id)) }); if (!r.ok) throw new Error(await r.text()); const { qty = 0 } = await r.json(); ownedMap.set(String(id), clampQty(qty)); applySelectionUIById(id); await loadRecipesOwned({ onEmpty: 'placeholder' }); updateOwnedCountUI(); if (ownedRecipesCount() === 0) closeOwnedIfOpen(); } catch (e) { console.error('removeRecette error', e); } } async function clearRecipe(id) { try { const r = await fetch('/HelloFresh/ClearRecipeOwned', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(String(id)) }); if (!r.ok) throw new Error(await r.text()); ownedMap.set(String(id), 0); applySelectionUIById(id); await loadRecipesOwned({ onEmpty: 'placeholder' }); updateOwnedCountUI(); if (ownedRecipesCount() === 0) closeOwnedIfOpen(); } catch (e) { console.error('clearRecipe error', e); } } /*********************** * Cards ***********************/ function setSelectedUI(card, selected) { if (!card) return; const slot = card.querySelector('.selection-slot'); if (!slot) return; slot.classList.toggle('active', !!selected); slot.innerHTML = selected ? `
Sélectionné
` : ''; } function applySelectionUIById(id) { const card = document.getElementById(String(id)); const selected = (ownedMap.get(String(id)) || 0) > 0; setSelectedUI(card, selected); } function ownedRecipesCount() { let c = 0; for (const v of ownedMap.values()) if ((Number(v) || 0) > 0) c++; return c; } function buildRecipeCardList(recipe) { const card = document.createElement('div'); card.className = 'card'; card.id = recipe.id; const prep = recipe.tempsDePreparation ? recipe.tempsDePreparation + "min" : "Inconnu"; card.innerHTML = `
${truncate(recipe.name, 10)}

${recipe.name}

${prep}
`; card.dataset.pdf = recipe.pdf || recipe.Pdf || ''; const tagsVal = (recipe.tags ?? recipe.Tags ?? '').trim(); if (tagsVal) { card.querySelector('.image-container')?.appendChild(buildLabels(tagsVal)); } const initiallySelected = (ownedMap.get(String(recipe.id)) || 0) > 0; setSelectedUI(card, initiallySelected); const tagTokens = splitTags(tagsVal).map(normalize); card.dataset.tags = tagTokens.join('|'); card.dataset.time = String(recipe.tempsDePreparation || ''); attachRecipeTooltip(card, recipe); flagCardIfDone(card, recipe.id); card.addEventListener('click', async (e) => { if (e.ctrlKey) { e.preventDefault(); e.stopPropagation(); // 1) ce que renvoie la liste let pdfUrl = recipe.pdf || recipe.Pdf || card.dataset.pdf || ''; // 2) fallback: demande le détail (si tu exposes pdf côté serveur) if (!pdfUrl) { try { const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(recipe.id)}`); if (res.ok) { const arr = await res.json(); pdfUrl = (arr?.[0]?.pdf || arr?.[0]?.Pdf || ''); } } catch (_) { } } if (!pdfUrl) { console.warn('Pas de PDF pour la recette', recipe.id); return; } const proxied = `/HelloFresh/ProxyPdf?url=${encodeURIComponent(pdfUrl)}`; showPdfModal(proxied); return; } const current = clampQty(ownedMap.get(String(recipe.id)) || 0); if (current === 0 && !canAddPortion(recipe.id)) { // message seulement s'il y a vraiment une limite if (Number.isFinite(MAX_TOTAL)) alert(`Limite atteinte (${MAX_TOTAL}).`); else if (Number.isFinite(MAX_PER_RECIPE)) alert(`Limite par recette atteinte (${MAX_PER_RECIPE}).`); return; }; const selectedNow = current > 0; if (!selectedNow) await saveRecipe(recipe.id); else await clearRecipe(recipe.id); applySelectionUIById(recipe.id); updateOwnedCountUI(); }); return card; } function buildRecipeCardOwned(recipe) { const qty = clampQty(recipe.portions || 0); const card = document.createElement('div'); card.className = 'card'; card.id = 'owned-' + recipe.id; // 🟢 stocke l'URL PDF sur la carte card.dataset.pdf = recipe.pdf || recipe.Pdf || ''; card.innerHTML = `
${truncate(recipe.name, 10)}
${qty} portion${qty > 1 ? 's' : ''}

${recipe.name}

${recipe.tempsDePreparation}min
${qty}
`; const tagsVal = (recipe.tags ?? recipe.Tags ?? '').trim(); if (tagsVal) { card.querySelector('.image-container')?.appendChild(buildLabels(tagsVal)); } // plus/moins existants card.querySelector('.btn-minus')?.addEventListener('click', (e) => { e.stopPropagation(); removeRecette(recipe.id); updateOwnedCountUI(); if (getTotalPortionsFromMap() === 0) closeOwnedIfOpen(); }); card.querySelector('.btn-plus')?.addEventListener('click', (e) => { e.stopPropagation(); if (!canAddPortion(recipe.id)) { if (Number.isFinite(MAX_TOTAL)) alert(`Limite atteinte (${MAX_TOTAL}).`); else if (Number.isFinite(MAX_PER_RECIPE)) alert(`Limite par recette atteinte (${MAX_PER_RECIPE}).`); return; } saveRecipe(recipe.id); updateOwnedCountUI(); }); // 🟢 Ctrl+clic = ouvrir le PDF (avec fallback GetRecipesDetails) card.addEventListener('click', async (e) => { if (!e.ctrlKey) return; e.preventDefault(); e.stopPropagation(); let pdfUrl = card.dataset.pdf; if (!pdfUrl) { try { const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(recipe.id)}`); if (res.ok) { const arr = await res.json(); pdfUrl = (arr?.[0]?.pdf || arr?.[0]?.Pdf || ''); } } catch { } } if (!pdfUrl) { alert("Aucun PDF pour cette recette."); return; } const proxied = `/HelloFresh/ProxyPdf?url=${encodeURIComponent(pdfUrl)}`; showPdfModal(proxied); }); return card; } /*********************** * Loaders ***********************/ async function loadRecipes(page = 1) { try { const url = new URL('/HelloFresh/GetRecipesByPage', window.location.origin); url.searchParams.set('page', page); url.searchParams.set('count', countPerPage); const res = await fetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const { recipes, currentPage: serverPage, lastPage } = data; currentPage = serverPage; lastPageGlobal = lastPage; const grid = document.getElementById('recipeGrid'); grid.innerHTML = ''; if (!recipes || recipes.length === 0) { grid.innerHTML = '

Aucune recette disponible pour le moment.

'; renderPagination(currentPage, lastPageGlobal); return; } recipes.forEach(r => grid.appendChild(buildRecipeCardList(r))); if (window.tippy) { tippy('[data-tippy-content]', { placement: 'top', arrow: true, delay: [100, 0], theme: 'light-border', maxWidth: 250, allowHTML: true }); } await hydrateIngredientsForPage(recipes); refreshSelectedBorders(); if ((currentSearchTerm ?? '').trim() !== '') applySearchFilter(); refreshDoneFlagsOnVisibleCards(); renderPagination(currentPage, lastPageGlobal); } catch (e) { console.error('loadRecipes error', e); } } async function loadRecipesOwned({ onEmpty = 'placeholder' } = {}) { const gridOwned = document.getElementById('recipeGridOwned'); if (!gridOwned) return false; const wasOpen = document.getElementById('recipeOwnedWrap')?.classList.contains('open'); try { const res = await fetch('/HelloFresh/GetRecipesOwned', { credentials: 'same-origin' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); ownedMap.clear(); (data || []).forEach(r => ownedMap.set(String(r.id), clampQty(Number(r.portions) || 0))); gridOwned.innerHTML = ''; if (Array.isArray(data) && data.length) { data.forEach(r => gridOwned.appendChild(buildRecipeCardOwned(r))); return true; } gridOwned.innerHTML = (onEmpty === 'placeholder') ? `

Aucune recette sélectionnée

` : ''; return false; } catch (err) { console.error('GetRecipesOwned a échoué :', err); gridOwned.innerHTML = `

Impossible de charger les recettes choisies.

`; return false; } finally { refreshSelectedBorders(); if (wasOpen) { /* no-op */ } } } /*********************** * PDF modal ***********************/ function showPdfModal(pdfUrl) { const iframe = document.getElementById('pdfFrame'); iframe.src = pdfUrl; $('#pdfModal').modal('show'); } function hidePdfModal() { const modal = document.getElementById('pdfModal'); const iframe = document.getElementById('pdfFrame'); iframe.src = ''; modal.style.display = 'none'; } document.querySelector('#pdfModal .close')?.addEventListener('click', hidePdfModal); window.addEventListener('click', (e) => { const modal = document.getElementById('pdfModal'); if (modal && e.target === modal) hidePdfModal(); }); /*********************** * History tooltip ***********************/ const historyCache = new Map(); async function checkRecipeInHistory(recipeId) { const key = String(recipeId); if (historyCache.has(key)) return historyCache.get(key); try { const r = await fetch('/HelloFresh/HasHistory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(key) }); if (!r.ok) throw 0; const json = await r.json(); const exists = Boolean(json?.exists ?? json?.hasHistory ?? json === true); historyCache.set(key, exists); return exists; } catch { historyCache.set(key, false); return false; } } function createRecipeTooltipNode(recipe) { const supplement = capFirst((recipe.supplementText ?? recipe.SupplementText ?? '').trim()); const descriptionRaw = (recipe.description ?? recipe.Description ?? '').trim(); const description = (descriptionRaw === '.') ? '' : capFirst(descriptionRaw); const root = document.createElement('div'); root.className = 'tooltip-wrap'; root.innerHTML = ` ${supplement ? `
${esc(supplement)}
` : ''} ${description ? `
${esc(description).replace(/\n/g, '
')}
` : ''}
`; return root; } function attachRecipeTooltip(card, recipe) { if (!window.tippy) return; const contentNode = createRecipeTooltipNode(recipe); const historyEl = contentNode.querySelector('.tt-history'); tippy(card, { content: contentNode, allowHTML: true, interactive: true, placement: 'top-start', theme: 'light-border', arrow: true, maxWidth: 360, onShow: async (instance) => { if (instance._historyLoaded) return; instance._historyLoaded = true; if (historyEl) historyEl.textContent = ''; const done = await checkRecipeInHistory(recipe.id); if (done && historyEl) { historyEl.textContent = '✓ déjà fait'; historyEl.style.color = '#2e7d32'; historyEl.style.fontWeight = '800'; } } }); } (function upsertUiStyles() { const id = 'tt-style'; const css = ` .tooltip-wrap{display:grid;gap:6px;max-width:360px;font-size:.85rem} .tooltip-wrap .tt-title{font-weight:700;font-size:.9rem} .tooltip-wrap .tt-desc{font-size:.85rem;line-height:1.35} .tooltip-wrap .tt-foot{display:flex;justify-content:flex-end} .tooltip-wrap .tt-history{font-size:.8rem;opacity:.9} .card.card--done{opacity:.35; transition:opacity .15s ease} .card.card--done:hover{opacity:.7}`; let style = document.getElementById(id); if (!style) { style = document.createElement('style'); style.id = id; document.head.appendChild(style); } style.textContent = css; })(); async function flagCardIfDone(card, id) { try { const done = await checkRecipeInHistory(id); card.classList.toggle('card--done', !!done); } catch { } } function refreshDoneFlagsOnVisibleCards() { document.querySelectorAll('#recipeGrid .card').forEach(card => { const id = card?.id; if (id) checkRecipeInHistory(id).then(done => card.classList.toggle('card--done', !!done)); }); } /*********************** * Filtering (ALL pages) ***********************/ function anyFilterActive() { return (normalize(currentSearchTerm) !== '') || appliedFilters.ingredients.length || appliedFilters.tags.length || appliedFilters.timeMin != null || appliedFilters.timeMax != null; } async function fetchAllRecipes() { if (ALL_RECIPES_CACHE) return ALL_RECIPES_CACHE; let page = 1, last = 1, all = []; do { const url = new URL('/HelloFresh/GetRecipesByPage', window.location.origin); url.searchParams.set('page', page); url.searchParams.set('count', countPerPage); const res = await fetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); all.push(...(data.recipes || [])); last = data.lastPage || 1; page++; } while (page <= last); ALL_RECIPES_CACHE = all; return all; } function recipeMatchesFilters(cardMeta) { const term = normalize(currentSearchTerm); if (term && !cardMeta.name.includes(term)) return false; if (appliedFilters.tags.length && !appliedFilters.tags.every(t => cardMeta.tags.includes(t))) return false; if (appliedFilters.ingredients.length && !appliedFilters.ingredients.every(i => cardMeta.ing.includes(i))) return false; const tmin = appliedFilters.timeMin, tmax = appliedFilters.timeMax; if (tmin != null && (cardMeta.time ?? 0) < tmin) return false; if (tmax != null && (cardMeta.time ?? 0) > tmax) return false; return true; } function extractIngredientNames(ingredientsJson) { const out = new Set(); try { const obj = JSON.parse(ingredientsJson || "{}"); Object.keys(obj).forEach(k => { const arr = obj[k]; if (Array.isArray(arr)) { arr.forEach(el => { const name = (el?.Name ?? el?.name ?? "").toString().trim(); if (name) out.add(name); }); } }); } catch { } return [...out]; } async function buildMetaFor(recipes) { const ids = recipes.map(r => r.id).join(','); const metaById = {}; try { const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(ids)}`); const details = await res.json(); // [{id, ingredientsJson}] details.forEach(d => { metaById[d.id] = { ing: extractIngredientNames(d.ingredientsJson).map(normalize) }; }); } catch (e) { console.warn('details fail', e); } recipes.forEach(r => { const tagsVal = (r.tags ?? r.Tags ?? '').trim(); const tags = splitTags(tagsVal).map(normalize); const time = Number(r.tempsDePreparation || 0) || 0; const name = normalize(r.name || ''); metaById[r.id] = Object.assign({ tags, time, name, ing: [] }, metaById[r.id] || {}); }); return metaById; } async function applyAllFilters() { if (!anyFilterActive()) { CURRENT_CLIENT_LIST = null; await loadRecipes(1); updateActiveFilterBadge(); // peut masquer le badge si rien d’actif return; } const all = await fetchAllRecipes(); const meta = await buildMetaFor(all); const filtered = all.filter(r => recipeMatchesFilters(meta[r.id] || {})); CURRENT_CLIENT_LIST = filtered; currentPage = 1; lastPageGlobal = Math.max(1, Math.ceil(filtered.length / countPerPage)); await renderClientPage(); updateActiveFilterBadge(); // badge = basé sur appliedFilters UNIQUEMENT } async function renderClientPage() { const list = CURRENT_CLIENT_LIST || []; const start = (currentPage - 1) * countPerPage; const slice = list.slice(start, start + countPerPage); const grid = document.getElementById('recipeGrid'); grid.innerHTML = ''; slice.forEach(r => grid.appendChild(buildRecipeCardList(r))); await hydrateIngredientsForPage(slice); refreshSelectedBorders(); refreshDoneFlagsOnVisibleCards(); lastPageGlobal = Math.max(1, Math.ceil(list.length / countPerPage)); renderPagination(currentPage, lastPageGlobal); if (slice.length === 0) { const p = document.createElement('p'); p.className = 'placeholder'; p.textContent = 'Aucune recette ne correspond aux filtres'; grid.appendChild(p); } } /*********************** * Filters Card (UI) ***********************/ function ensureSelect2() { return !!(window.jQuery && jQuery.fn && typeof jQuery.fn.select2 === 'function'); } async function populateFilters() { const ingSel = document.getElementById('filterIngredients'); const tagSel = document.getElementById('filterTags'); if (!ingSel || !tagSel) return; ingSel.innerHTML = ''; tagSel.innerHTML = ''; labelMaps.ingredients = {}; labelMaps.tags = {}; try { const [ingsRes, tagsRes] = await Promise.all([fetch('/HelloFresh/GetAllIngredients'), fetch('/HelloFresh/GetAllTags')]); const ingredients = ingsRes.ok ? await ingsRes.json() : []; const tags = tagsRes.ok ? await tagsRes.json() : []; (ingredients || []).forEach(lbl => { const val = normalize(lbl); labelMaps.ingredients[val] = lbl; ingSel.add(new Option(lbl, val)); }); (tags || []).forEach(lbl => { const val = normalize(lbl); labelMaps.tags[val] = lbl; tagSel.add(new Option(lbl, val)); }); if (ensureSelect2()) { const parent = jQuery('#filtersCard'); const $ing = jQuery(ingSel).select2({ width: '100%', placeholder: 'Choisir des ingrédients', allowClear: true, closeOnSelect: false, dropdownParent: parent }); const $tag = jQuery(tagSel).select2({ width: '100%', placeholder: 'Choisir des tags', allowClear: true, closeOnSelect: false, dropdownParent: parent }); // Écrit dans le DRAFT uniquement $ing.on('change', () => { draftFilters.ingredients = ($ing.val() || []).map(String); renderChipCloud('ingredients'); }); $tag.on('change', () => { draftFilters.tags = ($tag.val() || []).map(String); renderChipCloud('tags'); }); } else { ingSel.addEventListener('change', () => { draftFilters.ingredients = Array.from(ingSel.selectedOptions).map(o => o.value); renderChipCloud('ingredients'); }); tagSel.addEventListener('change', () => { draftFilters.tags = Array.from(tagSel.selectedOptions).map(o => o.value); renderChipCloud('tags'); }); } } catch (e) { console.error('populateFilters failed', e); } } // une seule fois, après que la carte existe document.getElementById('filtersCard')?.addEventListener('click', (e) => { e.stopPropagation(); // ⬅️ tout clic dans la carte reste dans la carte }); document.addEventListener('click', (e) => { const card = document.getElementById('filtersCard'); const btn = document.getElementById('filterOpenBtn'); if (!card || card.hidden) return; // si le clic vient du bouton ou de la carte → ne pas fermer if (e.target.closest('#filtersCard') || e.target.closest('#filterOpenBtn')) return; closeFiltersUI(); }); document.querySelector('.navbar-search')?.addEventListener('submit', (e) => e.preventDefault()); function renderChipCloud(kind) { const wrap = document.getElementById(kind === 'ingredients' ? 'chipIngredients' : 'chipTags'); if (!wrap) return; wrap.innerHTML = ''; (draftFilters[kind] || []).forEach(val => { const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'hf-chip active'; chip.dataset.value = val; chip.textContent = labelMaps[kind][val] || val; // chips "Ingrédients/Tags" (dans renderChipCloud) chip.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // ⬅️ bloque le click-outside const next = draftFilters[kind].filter(v => v !== val); draftFilters[kind] = next; const selId = (kind === 'ingredients') ? '#filterIngredients' : '#filterTags'; if (ensureSelect2()) { jQuery(selId).val(next).trigger('change.select2'); } else { const sel = document.querySelector(selId); [...sel.options].forEach(o => o.selected = next.includes(o.value)); sel.dispatchEvent(new Event('change', { bubbles: true })); } renderChipCloud(kind); }); wrap.appendChild(chip); }); } // Temps rapides (<= 15 / 30 / 45 / 60) function wireQuickTimeChips() { // chips "≤ 15/30/45/60" document.querySelectorAll('.hf-chip[data-tmax]')?.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // ⬅️ document.querySelectorAll('.hf-chip[data-tmax]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const tmax = Number(btn.dataset.tmax); const minInput = document.getElementById('filterTimeMin'); const maxInput = document.getElementById('filterTimeMax'); if (minInput) minInput.value = ''; if (maxInput) maxInput.value = String(tmax); draftFilters.timeMin = null; draftFilters.timeMax = tmax; }); }); } function wireTimeInputs() { const tminInput = document.getElementById('filterTimeMin'); const tmaxInput = document.getElementById('filterTimeMax'); [tminInput, tmaxInput].forEach(inp => { inp?.addEventListener('input', () => { document.querySelectorAll('.hf-chip[data-tmax]').forEach(b => b.classList.remove('active')); const v = Number(inp.value); const val = (inp.value === '' || Number.isNaN(v)) ? null : Math.max(0, v); if (inp === tminInput) draftFilters.timeMin = val; else draftFilters.timeMax = val; }); }); } function updateActiveFilterBadge() { const b = document.getElementById('activeFilterBadge'); if (!b) return; // si la carte de filtres est ouverte → ne rien afficher const cardOpen = !document.getElementById('filtersCard')?.hidden; if (cardOpen) { b.style.display = 'none'; b.textContent = ''; return; } // Badge basé UNIQUEMENT sur les filtres appliqués (pas les choix en cours) const n = (appliedFilters.ingredients.length ? 1 : 0) + (appliedFilters.tags.length ? 1 : 0) + ((appliedFilters.timeMin != null || appliedFilters.timeMax != null) ? 1 : 0) + (normalize(currentSearchTerm) ? 1 : 0); if (n > 0) { b.style.display = ''; b.textContent = `${n} filtre${n > 1 ? 's' : ''} actif${n > 1 ? 's' : ''}`; } else { b.style.display = 'none'; b.textContent = ''; } } function openFiltersUI() { const card = document.getElementById('filtersCard'); if (!card || !card.hidden) return; // Sync du draft depuis l'appliqué draftFilters = JSON.parse(JSON.stringify(appliedFilters)); // Refléter l'état dans l'UI if (ensureSelect2()) { jQuery('#filterIngredients').val(draftFilters.ingredients).trigger('change.select2'); jQuery('#filterTags').val(draftFilters.tags).trigger('change.select2'); } else { const ingSel = document.getElementById('filterIngredients'); const tagSel = document.getElementById('filterTags'); [...(ingSel?.options || [])].forEach(o => o.selected = draftFilters.ingredients.includes(o.value)); [...(tagSel?.options || [])].forEach(o => o.selected = draftFilters.tags.includes(o.value)); ingSel?.dispatchEvent(new Event('change', { bubbles: true })); tagSel?.dispatchEvent(new Event('change', { bubbles: true })); } document.getElementById('filterTimeMin').value = draftFilters.timeMin ?? ''; document.getElementById('filterTimeMax').value = draftFilters.timeMax ?? ''; document.querySelectorAll('.hf-chip[data-tmax]').forEach(b => { b.classList.toggle('active', Number(b.dataset.tmax) === draftFilters.timeMax && draftFilters.timeMin == null); }); renderChipCloud('ingredients'); renderChipCloud('tags'); // Ouvrir : enlève [hidden] et AJOUTE la classe .open card.hidden = false; // petite rafraîch pour la transition si tu veux requestAnimationFrame(() => card.classList.add('open')); updateActiveFilterBadge(); // cache le badge pendant l’édition } function closeFiltersUI() { const card = document.getElementById('filtersCard'); if (!card || card.hidden) return; card.classList.remove('open'); setTimeout(() => { card.hidden = true; }, 140); // laisser la transition se finir } function toggleFiltersUI() { const card = document.getElementById('filtersCard'); if (!card) return; (card.hidden) ? openFiltersUI() : closeFiltersUI(); } function wireFooterButtons() { const tminInput = document.getElementById('filterTimeMin'); const tmaxInput = document.getElementById('filterTimeMax'); document.getElementById('applyFilters')?.addEventListener('click', async () => { appliedFilters = JSON.parse(JSON.stringify(draftFilters)); // applique await applyAllFilters(); // filtre toutes les recettes updateActiveFilterBadge(); // badge MAJ ici seulement closeFiltersUI(); // ferme la card }); document.getElementById('clearFilters')?.addEventListener('click', async () => { // reset des deux états draftFilters = { ingredients: [], tags: [], timeMin: null, timeMax: null }; appliedFilters = { ingredients: [], tags: [], timeMin: null, timeMax: null }; // reset UI if (ensureSelect2()) { jQuery('#filterIngredients').val(null).trigger('change.select2'); jQuery('#filterTags').val(null).trigger('change.select2'); } else { const ingSel = document.getElementById('filterIngredients'); const tagSel = document.getElementById('filterTags'); [...(ingSel?.options || [])].forEach(o => o.selected = false); [...(tagSel?.options || [])].forEach(o => o.selected = false); ingSel?.dispatchEvent(new Event('change', { bubbles: true })); tagSel?.dispatchEvent(new Event('change', { bubbles: true })); } tminInput && (tminInput.value = ''); tmaxInput && (tmaxInput.value = ''); document.querySelectorAll('.hf-chip[data-tmax]').forEach(b => b.classList.remove('active')); renderChipCloud('ingredients'); renderChipCloud('tags'); // applique filtre vide + badge off + ferme await applyAllFilters(); updateActiveFilterBadge(); closeFiltersUI(); }); } /*********************** * Misc ***********************/ function getTotalPortionsFromMap() { let total = 0; ownedMap.forEach(v => { total += (Number(v) || 0); }); return total; } function updateOwnedCountUI() { const el = document.getElementById('ownedCount'); if (!el) return; const total = getTotalPortionsFromMap(); if (Number.isFinite(MAX_TOTAL)) { el.textContent = `(${total}/${MAX_TOTAL})`; el.style.color = (total >= MAX_TOTAL) ? '#c62828' : ''; } else { // affichage sans borne el.textContent = `(${total})`; el.style.color = ''; } } function canAddPortion(forRecipeId) { // limite globale if (Number.isFinite(MAX_TOTAL) && getTotalPortionsFromMap() >= MAX_TOTAL) return false; // limite par recette if (Number.isFinite(MAX_PER_RECIPE)) { const curr = Number(ownedMap.get(String(forRecipeId)) || 0); return curr < MAX_PER_RECIPE; } // illimité return true; } /*********************** * Ingredients meta attach (for page) ***********************/ async function hydrateIngredientsForPage(recipes) { try { const ids = recipes.map(r => r.id).join(','); if (!ids) return; const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(ids)}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const details = await res.json(); details.forEach(d => { const card = document.getElementById(d.id); if (!card) return; const names = extractIngredientNames(d.ingredientsJson).map(normalize); card.dataset.ing = names.join('|'); }); } catch (e) { console.error('hydrateIngredientsForPage error', e); } } /*********************** * STARTUP ***********************/ document.addEventListener('DOMContentLoaded', async () => { // Live search const searchInput = document.querySelector('#divSearch input'); if (searchInput) { searchInput.addEventListener('input', async (e) => { currentSearchTerm = e.target.value; await applyAllFilters(); // search est immédiat }); } // Owned await loadRecipesOwned({ onEmpty: 'auto' }); updateOwnedCountUI(); // Liste initiale (page 1) await loadRecipes(1); // Archive all const btnAll = document.getElementById('archiveAllBtn'); btnAll?.addEventListener('click', async () => { const total = getTotalPortionsFromMap(); if (total === 0) return; const ok = confirm(`Archiver tout (${total} portions) ?`); if (!ok) return; try { const r = await fetch('/HelloFresh/ArchiveAll', { method: 'POST' }); if (!r.ok) throw new Error(await r.text()); ownedMap.clear(); await loadRecipesOwned({ onEmpty: 'placeholder' }); refreshSelectedBorders(); updateOwnedCountUI(); closeOwnedIfOpen(); } catch (e) { console.error('ArchiveAll failed', e); alert('Impossible d’archiver toutes les recettes.'); } }); // Filtres (UI + data) await populateFilters(); // --- Ouvrir / fermer la carte de filtres --- const openBtn = document.getElementById('filterOpenBtn'); const card = document.getElementById('filtersCard'); if (openBtn && card && !openBtn._wired) { openBtn._wired = true; // anti-double-bind // toggle au clic openBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleFiltersUI(); // -> utilise openFiltersUI()/closeFiltersUI() }); // fermer en cliquant à l'extérieur document.addEventListener('click', (e) => { if (card.hidden) return; if (card.contains(e.target) || openBtn.contains(e.target)) return; closeFiltersUI(); }); // fermer avec ÉCHAP window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !card.hidden) closeFiltersUI(); }); } wireQuickTimeChips(); wireTimeInputs(); wireFooterButtons(); // Badge initial (rien d’actif) updateActiveFilterBadge(); });