879 lines
39 KiB
JavaScript
879 lines
39 KiB
JavaScript
/***********************
|
||
* 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 ? `<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(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 = `
|
||
<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(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 = '<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 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();
|
||
});
|