Files
administration/wwwroot/js/HelloFresh/index.js
2025-09-03 20:17:50 +02:00

843 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/***********************
* STATE
***********************/
let currentPage = 1;
let lastPageGlobal = 1;
const countPerPage = 12;
let currentSearchTerm = '';
const MAX_PORTIONS = 14;
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();
/***********************
* UTILS
***********************/
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 ?? ''));
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 ? `<div class="selection-indicator"><span class="selection-text">Sélectionné</span></div>` : '';
}
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" : "<i>Inconnu</i>";
card.innerHTML = `
<div class="image-container">
<img src="${recipe.image}" alt="${truncate(recipe.name, 10)}">
<div class="badge-portions" style="display:none"></div>
</div>
<div class="card-content">
<h3>${recipe.name}</h3>
<div class="footerCard">
<div class="selection-slot"></div>
<div class="recipe-info"><span class="prep-time">${prep}</span></div>
</div>
</div>`;
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()) { alert(`Limite atteinte (${MAX_PORTIONS}).`); 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 = `
<div class="image-container">
<img src="${recipe.image}" alt="${truncate(recipe.name, 10)}">
<div class="badge-portions">${qty} portion${qty > 1 ? 's' : ''}</div>
</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 class="quantity-controls">
<button class="btn-minus" type="button"></button>
<span class="quantity">${qty}</span>
<button class="btn-plus" type="button">+</button>
</div>
</div>
</div>`;
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()) { alert(`Limite atteinte (${MAX_PORTIONS}).`); 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 = '<p class="placeholder">Aucune recette disponible pour le moment.</p>';
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') ? `<p class="owned-empty">Aucune recette sélectionnée</p>` : '';
return false;
} catch (err) {
console.error('GetRecipesOwned a échoué :', err);
gridOwned.innerHTML = `<p class="owned-empty">Impossible de charger les recettes choisies.</p>`;
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 ? `<div class="tt-title">${esc(supplement)}</div>` : ''}
${description ? `<div class="tt-desc">${esc(description).replace(/\n/g, '<br>')}</div>` : ''}
<div class="tt-foot"><span class="tt-history" aria-live="polite"></span></div>`;
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 dactif
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(); el.textContent = `(${total}/${MAX_PORTIONS})`;
el.style.color = (total >= MAX_PORTIONS) ? '#c62828' : '';
}
function canAddPortion() { return getTotalPortionsFromMap() < MAX_PORTIONS; }
/***********************
* 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 darchiver 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 dactif)
updateActiveFilterBadge();
});