Ajout de HelloFresh
This commit is contained in:
210
wwwroot/js/Finances/index.js
Normal file
210
wwwroot/js/Finances/index.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Exemple d’utilisation :
|
||||
* Récupère les loyers avec un nombre de lignes défini
|
||||
* @param {number} rows - Nombre de lignes à récupérer
|
||||
*/
|
||||
|
||||
const Revenue = {
|
||||
GetXRevenues: "x_revenues",
|
||||
GetLastRevenues: "last_revenue",
|
||||
GetAllRevenues: "all_revenues",
|
||||
GetRevenueByID: "revenues_by_id",
|
||||
GetAdditionalRevenues: "additional_revenues"
|
||||
};
|
||||
|
||||
const Expense = {
|
||||
GetRightExpenses: "last_expenses",
|
||||
GetExpenseByID: "expense_by_id"
|
||||
}
|
||||
|
||||
var TotalRevenu = 0, TotalExpense = 0;
|
||||
|
||||
function loadRevenues() {
|
||||
return Promise.all([
|
||||
new Promise((resolve, reject) => {
|
||||
apiCall(Controller.Revenue, Revenue.GetXRevenues, { rows: 1 }, data => {
|
||||
document.getElementById('idRevenues').innerText = data[0].id;
|
||||
const loyer = document.getElementById('salaire');
|
||||
const salaire = parseFloat(data[0]?.salary || 0);
|
||||
if (loyer) loyer.innerText = `${toPriceFormat(salaire)}€`;
|
||||
resolve(salaire);
|
||||
}, reject);
|
||||
}),
|
||||
new Promise((resolve, reject) => {
|
||||
apiCall(Controller.Revenue, Revenue.GetAdditionalRevenues, {}, data => {
|
||||
const additionalRevenues = document.getElementById('additionalRevenues');
|
||||
const totalAR = data.reduce((acc, item) => acc + (parseFloat(item.amount) || 0), 0);
|
||||
if (additionalRevenues) additionalRevenues.innerText = `${toPriceFormat(totalAR)}€`;
|
||||
resolve(totalAR);
|
||||
}, reject);
|
||||
})
|
||||
]).then(([salary, additional]) => {
|
||||
TotalRevenu = salary + additional;
|
||||
});
|
||||
}
|
||||
|
||||
function loadExpenses() {
|
||||
return new Promise((resolve, reject) => {
|
||||
apiCall(Controller.Expense, Expense.GetRightExpenses, {}, data => {
|
||||
document.getElementById('idExpenses').innerText = data.id;
|
||||
|
||||
const loyer = document.getElementById('loyer');
|
||||
const trash = document.getElementById('trash');
|
||||
const electricity = document.getElementById('electricity');
|
||||
const insurance = document.getElementById('insurance');
|
||||
const wifi = document.getElementById('wifi');
|
||||
const groceries = document.getElementById('groceries');
|
||||
const additionalSourcesExpense = document.getElementById('additionalSourcesExpense');
|
||||
const additionalExpensesSub = document.getElementById('additionalExpensesSub');
|
||||
const saving = document.getElementById('saving');
|
||||
|
||||
if (loyer) loyer.innerText = `${toPriceFormat(data.rent)}€`;
|
||||
if (trash) trash.innerText = `${toPriceFormat(data.trash)}€`;
|
||||
if (insurance) insurance.innerText = `${toPriceFormat(data.insurance)}€`;
|
||||
if (electricity) electricity.innerText = `${toPriceFormat(data.electricity)}€`;
|
||||
if (wifi) wifi.innerText = `${toPriceFormat(data.wifi)}€`;
|
||||
if (groceries) groceries.innerText = `${toPriceFormat(data.groceries)}€`;
|
||||
if (saving) saving.innerText = `${toPriceFormat(data.saving)}€`;
|
||||
|
||||
if (additionalSourcesExpense) {
|
||||
const totalAR = data.additionalSourcesExpense.reduce((acc, item) => acc + (parseFloat(item.amount) || 0), 0);
|
||||
additionalSourcesExpense.innerText = `${toPriceFormat(totalAR)}€`;
|
||||
}
|
||||
|
||||
if (additionalExpensesSub) {
|
||||
const totalAR = data.additionalSourcesSub.reduce((acc, item) => acc + (parseFloat(item.amount) || 0), 0);
|
||||
additionalExpensesSub.innerText = `${toPriceFormat(totalAR)}€`;
|
||||
}
|
||||
|
||||
TotalExpense = data.total;
|
||||
resolve();
|
||||
}, reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Lance automatiquement l’appel au chargement de la page
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const result = document.getElementById('result');
|
||||
const monthDisplay = document.getElementById('monthOfToday');
|
||||
const cards = document.querySelectorAll('.cardDepense');
|
||||
|
||||
monthDisplay.textContent = returnMonthOfToday();
|
||||
|
||||
try {
|
||||
await Promise.all([loadExpenses(), loadRevenues()]);
|
||||
if (result) result.textContent = `${toPriceFormat(TotalRevenu - TotalExpense)}€`;
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur de chargement :", error);
|
||||
if (result) result.textContent = "Erreur lors du calcul";
|
||||
}
|
||||
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('click', () => onClickInCard(card));
|
||||
});
|
||||
});
|
||||
|
||||
function onClickInCard(card) {
|
||||
const label = document.getElementById("CRUDModalLabel");
|
||||
const idExpense = document.getElementById('idExpenses').innerText;
|
||||
|
||||
if (label) label.textContent = card.id.replace(/_/g, ' ').toUpperCase();
|
||||
|
||||
apiCall(Controller.Expense, Expense.GetExpenseByID, { id: idExpense }, data => {
|
||||
|
||||
initExpensesTable(data);
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('CRUDModal'));
|
||||
modal.show();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise ou met à jour dynamiquement une table DataTables avec les données de dépenses.
|
||||
*
|
||||
* - Si la table est déjà initialisée, elle est vidée et rechargée avec les nouvelles données.
|
||||
* - Sinon, la table est créée avec les colonnes correspondantes à l’objet Expense.
|
||||
*
|
||||
* @param {Array<Object>} data - Tableau d’objets de type Expense à afficher dans le tableau.
|
||||
* Chaque objet doit contenir : id, date, rent, electricity, trash, wifi, groceries, saving, insurance, userId.
|
||||
*/
|
||||
function initExpensesTable(data) {
|
||||
const tableId = '#expensesTable';
|
||||
const normalizedData = Array.isArray(data) ? data : [data];
|
||||
|
||||
if ($.fn.DataTable.isDataTable(tableId)) {
|
||||
$(tableId).DataTable().clear().rows.add(normalizedData).draw();
|
||||
return;
|
||||
}
|
||||
|
||||
$(tableId).DataTable({
|
||||
data: normalizedData,
|
||||
paging: false,
|
||||
searching: false,
|
||||
ordering: false,
|
||||
info: false,
|
||||
language: {
|
||||
url: "https://cdn.datatables.net/plug-ins/1.13.6/i18n/fr-FR.json"
|
||||
},
|
||||
columns: [
|
||||
{ data: 'id', visible: false }, // ID caché
|
||||
{
|
||||
data: 'date',
|
||||
visible: false // Date cachée
|
||||
},
|
||||
{
|
||||
data: 'rent',
|
||||
title: 'Loyer',
|
||||
render: data => `${data} €`
|
||||
},
|
||||
{
|
||||
data: 'electricity',
|
||||
title: 'Électricité',
|
||||
render: data => `${data} €`
|
||||
},
|
||||
{
|
||||
data: 'trash',
|
||||
title: 'Poubelles',
|
||||
render: data => `${data} €`
|
||||
},
|
||||
{
|
||||
data: 'wifi',
|
||||
title: 'Wi-Fi',
|
||||
render: data => `${data} €`
|
||||
},
|
||||
{
|
||||
data: 'groceries',
|
||||
title: 'Courses',
|
||||
render: data => `${data} €`
|
||||
},
|
||||
{
|
||||
data: 'saving',
|
||||
title: 'Épargne',
|
||||
render: data => `${data} €`
|
||||
},
|
||||
{
|
||||
data: 'insurance',
|
||||
title: 'Assurance',
|
||||
render: data => `${data} €`
|
||||
},
|
||||
{ data: 'userId', visible: false } // Utilisateur caché
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Retourne le nom du mois actuel en français avec la première lettre en majuscule.
|
||||
*
|
||||
* @returns {string} Le mois actuel, par exemple : "Août", "Mars", etc.
|
||||
*/
|
||||
function returnMonthOfToday() {
|
||||
const date = new Date();
|
||||
const moisActuel = date.toLocaleString('fr-FR', { month: 'long' });
|
||||
return moisActuel.charAt(0).toUpperCase() + moisActuel.slice(1);
|
||||
}
|
||||
|
||||
|
||||
288
wwwroot/js/HelloFresh/cuisine.js
Normal file
288
wwwroot/js/HelloFresh/cuisine.js
Normal file
@@ -0,0 +1,288 @@
|
||||
// ==============================
|
||||
// Cuisine / Lecteur de recettes
|
||||
// ==============================
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
// ---------- Sélecteurs ----------
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
const els = {
|
||||
root: $("#cuisineApp"),
|
||||
scroller: $("#recipeScroller"),
|
||||
title: $("#currentName"),
|
||||
btnOpen: $("#openPdf"),
|
||||
btnToggle: $("#toggleSide"),
|
||||
iframePrev: $("#pdfPreview"),
|
||||
placeholder: $("#pdfPlaceholder"),
|
||||
overlay: $("#viewerOverlay"),
|
||||
overlayTit: $("#viewerTitle"),
|
||||
iframeFull: $("#pdfFrameFull"),
|
||||
btnClose: $("#closeViewer"),
|
||||
};
|
||||
|
||||
// ---------- Etat ----------
|
||||
const PDF_DEFAULT_ZOOM = "page-fit"; // "page-fit" | "page-width" | "100"
|
||||
const LS_KEY_COLLAPSED = "cui_sidebar_collapsed";
|
||||
|
||||
/** @type {{id:string,name:string,image:string,tempsDePreparation:number|null,pdf:string,portions:number}[]} */
|
||||
let RECIPES = [];
|
||||
let CUR = -1; // index courant
|
||||
|
||||
// ---------- Utils ----------
|
||||
const normalize = (s) => (s ?? "").toString().trim().toLowerCase();
|
||||
const proxify = (url) => url ? `/HelloFresh/ProxyPdf?url=${encodeURIComponent(url)}` : "";
|
||||
|
||||
|
||||
function buildPdfSrc(rawUrl) {
|
||||
if (!rawUrl) return "";
|
||||
const base = `/HelloFresh/ProxyPdf?url=${encodeURIComponent(rawUrl)}`;
|
||||
// cache la barre (toolbar=0) + fixe le zoom
|
||||
return `${base}#toolbar=0&zoom=${encodeURIComponent(PDF_DEFAULT_ZOOM)}`;
|
||||
}
|
||||
|
||||
// Dédup par nom (on garde la 1ère qui a un pdf / image)
|
||||
function dedupByName(rows) {
|
||||
const map = new Map(); // key: normalized name -> row
|
||||
for (const r of rows) {
|
||||
const key = normalize(r.name);
|
||||
if (!key) continue;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, r);
|
||||
} else {
|
||||
const kept = map.get(key);
|
||||
// privilégier une entrée avec PDF / image
|
||||
if (!kept.pdf && r.pdf) map.set(key, r);
|
||||
else if (!kept.image && r.image) map.set(key, r);
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
// ---------- Rendu de la liste ----------
|
||||
function renderList(list) {
|
||||
const wrap = els.scroller;
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = "";
|
||||
|
||||
list.forEach((r, idx) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "cui-card";
|
||||
card.tabIndex = 0;
|
||||
card.dataset.idx = String(idx);
|
||||
|
||||
card.innerHTML = `
|
||||
<img class="cui-thumb" src="${r.image || ""}" alt="">
|
||||
<div class="cui-info">
|
||||
<div class="cui-name" title="${r.name}">${r.name}</div>
|
||||
<div class="cui-meta">${r.tempsDePreparation ? (r.tempsDePreparation + " min") : ""}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// clic / entrée -> sélection
|
||||
card.addEventListener("click", () => selectIdx(idx));
|
||||
card.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectIdx(idx);
|
||||
}
|
||||
});
|
||||
|
||||
wrap.appendChild(card);
|
||||
});
|
||||
|
||||
markActive();
|
||||
}
|
||||
|
||||
function markActive() {
|
||||
els.scroller?.querySelectorAll(".cui-card").forEach((el, i) => {
|
||||
el.classList.toggle("active", i === CUR);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Sélection / Toolbar / Preview ----------
|
||||
function selectIdx(idx) {
|
||||
if (idx < 0 || idx >= RECIPES.length) return;
|
||||
CUR = idx;
|
||||
const r = RECIPES[CUR];
|
||||
|
||||
// Titre (ellipsis géré par CSS)
|
||||
if (els.title) els.title.textContent = r.name || "Recette";
|
||||
|
||||
// Bouton ouvrir
|
||||
if (els.btnOpen) {
|
||||
const can = !!r.pdf;
|
||||
els.btnOpen.disabled = !can;
|
||||
els.btnOpen.setAttribute("aria-disabled", String(!can));
|
||||
els.btnOpen.title = can ? "Ouvrir le PDF" : "PDF indisponible";
|
||||
}
|
||||
|
||||
// Aperçu PDF
|
||||
loadPreview(r.pdf);
|
||||
|
||||
// Etat actif visuel
|
||||
markActive();
|
||||
}
|
||||
|
||||
function loadPreview(url) {
|
||||
if (!els.iframePrev || !els.placeholder) return;
|
||||
|
||||
if (url) {
|
||||
els.iframePrev.src = buildPdfSrc(url);
|
||||
els.iframePrev.style.display = "block";
|
||||
els.placeholder.style.display = "none";
|
||||
} else {
|
||||
els.iframePrev.removeAttribute("src");
|
||||
els.iframePrev.style.display = "none";
|
||||
els.placeholder.style.display = "grid";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Plein écran ----------
|
||||
function openViewer() {
|
||||
if (CUR < 0) return;
|
||||
const r = RECIPES[CUR];
|
||||
if (!r.pdf || !els.overlay || !els.iframeFull || !els.overlayTit) return;
|
||||
|
||||
els.overlayTit.textContent = r.name || "PDF";
|
||||
els.iframeFull.src = buildPdfSrc(r.pdf);
|
||||
els.overlay.classList.remove("hidden");
|
||||
els.overlay.setAttribute("aria-hidden", "false");
|
||||
// tente le plein écran (si autorisé par l’UA)
|
||||
try { els.overlay.requestFullscreen(); } catch { }
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
if (!els.overlay || !els.iframeFull) return;
|
||||
els.iframeFull.removeAttribute("src");
|
||||
els.overlay.classList.add("hidden");
|
||||
els.overlay.setAttribute("aria-hidden", "true");
|
||||
if (document.fullscreenElement) {
|
||||
try { document.exitFullscreen(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation dans le viewer
|
||||
function next() {
|
||||
if (CUR < RECIPES.length - 1) {
|
||||
selectIdx(CUR + 1);
|
||||
if (!els.overlay?.classList.contains("hidden") && els.iframeFull) {
|
||||
const r = RECIPES[CUR];
|
||||
els.iframeFull.src = buildPdfSrc(r.pdf || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
function prev() {
|
||||
if (CUR > 0) {
|
||||
selectIdx(CUR - 1);
|
||||
if (!els.overlay?.classList.contains("hidden") && els.iframeFull) {
|
||||
const r = RECIPES[CUR];
|
||||
els.iframeFull.src = buildPdfSrc(r.pdf || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Données ----------
|
||||
async function fetchRecipes() {
|
||||
const res = await fetch("/HelloFresh/GetRecipesOwned", { credentials: "same-origin" });
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const raw = await res.json();
|
||||
|
||||
// Normalisation des propriétés
|
||||
const norm = (r) => ({
|
||||
id: r.id ?? r.Id ?? "",
|
||||
name: r.name ?? r.Name ?? "",
|
||||
image: r.image ?? r.Image ?? "",
|
||||
tempsDePreparation: r.tempsDePreparation ?? r.TempsDePreparation ?? null,
|
||||
pdf: r.pdf ?? r.Pdf ?? "",
|
||||
portions: Number(r.portions ?? r.Portions ?? 0) || 0
|
||||
});
|
||||
|
||||
let rows = Array.isArray(raw) ? raw.map(norm) : [];
|
||||
rows = dedupByName(rows);
|
||||
|
||||
// Tri par nom (français)
|
||||
rows.sort((a, b) => String(a.name).localeCompare(String(b.name), "fr", { sensitivity: "base" }));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function loadRecipes() {
|
||||
try {
|
||||
RECIPES = await fetchRecipes();
|
||||
renderList(RECIPES);
|
||||
|
||||
// Auto-sélection : d'abord la 1ère avec PDF, sinon la 1ère tout court
|
||||
const firstPdf = RECIPES.findIndex(r => !!r.pdf);
|
||||
const idx = firstPdf >= 0 ? firstPdf : (RECIPES.length ? 0 : -1);
|
||||
if (idx >= 0) selectIdx(idx);
|
||||
else selectIdx(-1);
|
||||
} catch (err) {
|
||||
console.error("[Cuisine] GetRecipesOwned failed:", err);
|
||||
RECIPES = [];
|
||||
renderList([]);
|
||||
selectIdx(-1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Sidebar collapse (persisté) ----------
|
||||
function setCollapsed(collapsed) {
|
||||
if (!els.root) return;
|
||||
els.root.classList.toggle("collapsed", !!collapsed);
|
||||
els.btnToggle?.setAttribute("aria-expanded", String(!collapsed));
|
||||
try { localStorage.setItem(LS_KEY_COLLAPSED, JSON.stringify(!!collapsed)); } catch { }
|
||||
}
|
||||
function getCollapsed() {
|
||||
try {
|
||||
const v = localStorage.getItem(LS_KEY_COLLAPSED);
|
||||
return v ? JSON.parse(v) : false;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// ---------- Gestes / Clavier (viewer) ----------
|
||||
(function wireGestures() {
|
||||
if (!els.overlay) return;
|
||||
|
||||
// Swipe horizontal (tablette)
|
||||
let touchStartX = 0;
|
||||
const SWIPE_MIN = 60;
|
||||
els.overlay.addEventListener("touchstart", (e) => {
|
||||
if (!e.changedTouches?.length) return;
|
||||
touchStartX = e.changedTouches[0].clientX;
|
||||
}, { passive: true });
|
||||
|
||||
els.overlay.addEventListener("touchend", (e) => {
|
||||
if (!e.changedTouches?.length) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX;
|
||||
if (Math.abs(dx) > SWIPE_MIN) (dx < 0 ? next : prev)();
|
||||
}, { passive: true });
|
||||
|
||||
// Clavier (visible uniquement dans le viewer)
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (els.overlay?.classList.contains("hidden")) return;
|
||||
if (e.key === "ArrowRight") next();
|
||||
else if (e.key === "ArrowLeft") prev();
|
||||
else if (e.key === "Escape") closeViewer();
|
||||
});
|
||||
})();
|
||||
|
||||
// ---------- Wiring des boutons ----------
|
||||
function wireUI() {
|
||||
els.btnOpen?.addEventListener("click", openViewer);
|
||||
els.btnClose?.addEventListener("click", closeViewer);
|
||||
|
||||
els.btnToggle?.addEventListener("click", () => {
|
||||
setCollapsed(!els.root?.classList.contains("collapsed"));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Boot ----------
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Appliquer l’état collapsé mémorisé
|
||||
setCollapsed(getCollapsed());
|
||||
|
||||
wireUI();
|
||||
await loadRecipes();
|
||||
});
|
||||
|
||||
})();
|
||||
174
wwwroot/js/HelloFresh/historique.js
Normal file
174
wwwroot/js/HelloFresh/historique.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// ---------- ÉTAT ----------
|
||||
let HISTORY_CACHE = []; // [{ date, rawDate, items: [...] }]
|
||||
let HISTORY_SEARCH = "";
|
||||
|
||||
// Normalisation basique pour la recherche
|
||||
const ACCENT_RX = /[\u0300-\u036f]/g;
|
||||
const norm = s => (s || "").toString().toLowerCase()
|
||||
.normalize("NFD").replace(ACCENT_RX, "").trim();
|
||||
|
||||
// Palette de couleurs (plus large possible pour différencier)
|
||||
const USER_COLORS = [
|
||||
"#C0392B", // rouge foncé
|
||||
"#2980B9", // bleu foncé
|
||||
"#27AE60", // vert foncé
|
||||
"#D35400", // orange foncé
|
||||
"#8E44AD", // violet foncé
|
||||
"#2C3E50", // gris/bleu très foncé
|
||||
"#7D3C98", // violet profond
|
||||
"#1ABC9C" // turquoise foncé
|
||||
];
|
||||
// Cache pour assigner une couleur unique par user
|
||||
const userColorMap = new Map();
|
||||
let userColorIndex = 0;
|
||||
|
||||
function getUserColor(username) {
|
||||
if (!username) return "#999";
|
||||
if (!userColorMap.has(username)) {
|
||||
// Assigne une couleur en cycle
|
||||
userColorMap.set(username, USER_COLORS[userColorIndex % USER_COLORS.length]);
|
||||
userColorIndex++;
|
||||
}
|
||||
return userColorMap.get(username);
|
||||
}
|
||||
|
||||
function buildUserBadges(usersArr) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "label-container";
|
||||
(usersArr || []).forEach(u => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "label-badge user-badge";
|
||||
span.textContent = u;
|
||||
|
||||
// Couleur dynamique par utilisateur
|
||||
span.style.backgroundColor = getUserColor(u);
|
||||
span.style.color = "#fff"; // texte blanc pour lisibilité
|
||||
|
||||
wrap.appendChild(span);
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const truncate = (s, m) => (s && s.length > m ? s.slice(0, m) + "…" : (s ?? ""));
|
||||
|
||||
function buildRecipeCardHistory(rec) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card";
|
||||
card.id = `hist-${rec.id}`;
|
||||
|
||||
const name = rec.name || `Recette ${rec.id}`;
|
||||
const imgSrc = rec.image || "/images/recipe-placeholder.png"; // mets un vrai fichier ici
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="image-container">
|
||||
<img src="${imgSrc}" alt="${truncate(name, 10)}"
|
||||
onerror="this.onerror=null;this.src='/images/recipe-placeholder.png'">
|
||||
<div class="badge-portions">${rec.portions} portion${rec.portions > 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div class="card-content"><h3>${name}</h3></div>
|
||||
`;
|
||||
|
||||
// badges utilisateurs (JSON camelCase => "users")
|
||||
const users = Array.isArray(rec.users) ? rec.users : Array.isArray(rec.Users) ? rec.Users : [];
|
||||
if (users.length) {
|
||||
card.querySelector(".image-container")?.appendChild(buildUserBadges(users));
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
|
||||
function buildDateSection(dateStr, items) {
|
||||
const sec = document.createElement("section");
|
||||
sec.className = "history-section";
|
||||
|
||||
const h = document.createElement("h3");
|
||||
h.className = "history-date";
|
||||
h.textContent = dateStr;
|
||||
sec.appendChild(h);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "hf-grid";
|
||||
if (Array.isArray(items) && items.length) {
|
||||
items.forEach(r => grid.appendChild(buildRecipeCardHistory(r)));
|
||||
} else {
|
||||
grid.innerHTML = `<p class="placeholder">Aucune recette pour cette date.</p>`;
|
||||
}
|
||||
sec.appendChild(grid);
|
||||
return sec;
|
||||
}
|
||||
|
||||
// Applique la recherche sur le cache et rend
|
||||
function renderHistoryFiltered() {
|
||||
const container = document.getElementById("historyContainer");
|
||||
if (!container) return;
|
||||
|
||||
const q = norm(HISTORY_SEARCH);
|
||||
const groups = [];
|
||||
|
||||
for (const g of (HISTORY_CACHE || [])) {
|
||||
if (!q) {
|
||||
groups.push(g);
|
||||
continue;
|
||||
}
|
||||
const filteredItems = (g.items || []).filter(it => norm(it.name).includes(q));
|
||||
if (filteredItems.length) {
|
||||
groups.push({ date: g.date, rawDate: g.rawDate, items: filteredItems });
|
||||
}
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
if (!groups.length) {
|
||||
container.innerHTML = `<p class="placeholder">Aucun résultat pour “${HISTORY_SEARCH}”.</p>`;
|
||||
return;
|
||||
}
|
||||
groups.forEach(group => container.appendChild(buildDateSection(group.date, group.items)));
|
||||
}
|
||||
|
||||
// ---------- CHARGEMENT ----------
|
||||
async function loadHistory(scope = "mine") {
|
||||
const container = document.getElementById("historyContainer");
|
||||
if (!container) return;
|
||||
container.innerHTML = "Chargement…";
|
||||
try {
|
||||
const isUserImportant = (scope !== "all");
|
||||
const res = await fetch(`/HelloFresh/GetHistory?isUserImportant=${isUserImportant ? "true" : "false"}&search=${encodeURIComponent(HISTORY_SEARCH)}`, { credentials: "same-origin" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json(); // [{ date, rawDate, items }]
|
||||
|
||||
HISTORY_CACHE = Array.isArray(data) ? data : [];
|
||||
renderHistoryFiltered(); // premier rendu (avec éventuel terme déjà saisi)
|
||||
} catch (e) {
|
||||
console.error("loadHistory error:", e);
|
||||
container.innerHTML = `<p class="placeholder">Erreur de chargement.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- INIT ----------
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const scopeSel = document.getElementById("historyScope");
|
||||
const search = document.getElementById("historySearch");
|
||||
|
||||
// charge au démarrage
|
||||
loadHistory(scopeSel?.value || "mine");
|
||||
|
||||
// changement de portée
|
||||
scopeSel?.addEventListener("change", () => loadHistory(scopeSel.value || "mine"));
|
||||
|
||||
// recherche (debounce)
|
||||
let timer;
|
||||
search?.addEventListener("input", (e) => {
|
||||
HISTORY_SEARCH = e.target.value || "";
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(renderHistoryFiltered, 200);
|
||||
});
|
||||
|
||||
// Entrée = rendu immédiat
|
||||
search?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
clearTimeout(timer);
|
||||
renderHistoryFiltered();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
842
wwwroot/js/HelloFresh/index.js
Normal file
842
wwwroot/js/HelloFresh/index.js
Normal file
@@ -0,0 +1,842 @@
|
||||
/***********************
|
||||
* 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 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(); 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 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();
|
||||
});
|
||||
638
wwwroot/js/HelloFresh/ingredients.js
Normal file
638
wwwroot/js/HelloFresh/ingredients.js
Normal file
@@ -0,0 +1,638 @@
|
||||
// wwwroot/js/ingredients.js
|
||||
|
||||
// ======================
|
||||
// Globals & Helpers
|
||||
// ======================
|
||||
let OWNED_SET = new Set();
|
||||
let ALL_AGG_INGS = [];
|
||||
let AI_CTRL = null;
|
||||
const AI_DELAY = 300;
|
||||
const SEARCH_STATE = { query: "", mode: null };
|
||||
const els = { list: null, search: null };
|
||||
// --- Exclusions par recette ---
|
||||
const RECIPE_ING_CACHE = new Map(); // id -> { '1': [noms], '2': [...], ... }
|
||||
const EXCLUDED_BY_RECIPE = new Map(); // id -> Set(noms normalisés)
|
||||
// État “Ingrédients à avoir”
|
||||
let LAST_ING_TO_HAD = null;
|
||||
let LAST_PORTIONS = 1;
|
||||
let lastNeededSignature = "";
|
||||
let prevNotShippedCount = -1;
|
||||
|
||||
const ACCENT_RX = /[\u0300-\u036f]/g;
|
||||
const normalizeName = (s) => (s || "").trim().toLowerCase();
|
||||
const normalizeText = (s) => {
|
||||
let t = (s || "").toLowerCase();
|
||||
try { t = t.normalize("NFD").replace(ACCENT_RX, ""); } catch { }
|
||||
return t.replace(/\s/g, " ").trim();
|
||||
};
|
||||
|
||||
// Fractions unicode → décimaux
|
||||
const VULGAR_FRAC = { "½": "0.5", "¼": "0.25", "¾": "0.75", "⅓": "0.3333", "⅔": "0.6667", "⅛": "0.125", "⅜": "0.375", "⅝": "0.625", "⅞": "0.875" };
|
||||
|
||||
// Rang unités
|
||||
const UNIT_RANK = { "pièce": 6, "piece": 6, "pcs": 6, "pc": 6, "cs": 5, "tbsp": 5, "cc": 4, "tsp": 4, "g": 3, "kg": 3, "mg": 3, "ml": 3, "cl": 3, "l": 3, "": 1 };
|
||||
|
||||
const nameKey = (s) => (s || "").trim().toLowerCase();
|
||||
|
||||
// --- Exclusions par recette (ingrédients "principaux" ET "à avoir") ---
|
||||
const EXCLUDED_NEEDED_BY_RECIPE = new Map(); // id -> Set(noms normalisés)
|
||||
|
||||
|
||||
function excludedUnionFrom(map) {
|
||||
const out = new Set();
|
||||
for (const set of map.values()) for (const n of set) out.add(n);
|
||||
return out;
|
||||
}
|
||||
function applyNeededExclusions(items) {
|
||||
if (EXCLUDED_NEEDED_BY_RECIPE.size === 0) return items;
|
||||
const ex = excludedUnionFrom(EXCLUDED_NEEDED_BY_RECIPE);
|
||||
return (items || []).filter(it => !ex.has(nameKey(it?.Name)));
|
||||
}
|
||||
|
||||
// Récupère les noms "à avoir" d’une recette (à partir de l’objet recette du strip)
|
||||
function getNeededNamesForRecipeObj(recipe, portions) {
|
||||
if (!recipe) return [];
|
||||
const det = detectIngredientsToHadProp(recipe);
|
||||
const arr = det ? (det.parsed?.[String(portions)] || []) : [];
|
||||
return (Array.isArray(arr) ? arr : [])
|
||||
.map(el => (el?.Name ?? el?.name ?? "").toString().trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
(function addExcludeStyle() {
|
||||
const id = "hf-exclude-style";
|
||||
if (document.getElementById(id)) return;
|
||||
const st = document.createElement("style"); st.id = id;
|
||||
st.textContent = `
|
||||
#recipeStrip .card.card--excluded{ opacity:.45; outline:2px dashed #888; }
|
||||
#recipeStrip .card.card--excluded:hover{ opacity:.7; }
|
||||
`;
|
||||
document.head.appendChild(st);
|
||||
})();
|
||||
|
||||
// Retourne les noms d'ingrédients (par portion 1..4) pour une recette
|
||||
async function getIngredientNamesForRecipe(recipeId, portions = 1) {
|
||||
const id = String(recipeId);
|
||||
if (!RECIPE_ING_CACHE.has(id)) {
|
||||
try {
|
||||
const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(id)}`);
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const arr = await res.json();
|
||||
const one = Array.isArray(arr) ? arr.find(x => String(x.id) === id) : null;
|
||||
const map = {};
|
||||
if (one && one.ingredientsJson) {
|
||||
try {
|
||||
const obj = JSON.parse(one.ingredientsJson);
|
||||
for (const k of ["1", "2", "3", "4"]) {
|
||||
const list = Array.isArray(obj[k]) ? obj[k] : [];
|
||||
map[k] = list.map(el => (el?.Name ?? el?.name ?? "").toString().trim()).filter(Boolean);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
RECIPE_ING_CACHE.set(id, map);
|
||||
} catch (e) {
|
||||
console.warn("[ingredients] GetRecipesDetails fail for", id, e);
|
||||
RECIPE_ING_CACHE.set(id, {}); // évite re-fetch en boucle
|
||||
}
|
||||
}
|
||||
const cache = RECIPE_ING_CACHE.get(id) || {};
|
||||
return (cache[String(portions)] || []).slice();
|
||||
}
|
||||
|
||||
// Union des exclusions (Set global)
|
||||
function excludedUnion() {
|
||||
const out = new Set();
|
||||
for (const set of EXCLUDED_BY_RECIPE.values()) {
|
||||
for (const n of set) out.add(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Filtre une liste d’items en appliquant les exclusions
|
||||
function applyExclusionsTo(items) {
|
||||
if (EXCLUDED_BY_RECIPE.size === 0) return items;
|
||||
const ex = excludedUnion();
|
||||
return (items || []).filter(it => !ex.has(nameKey(it?.Name)));
|
||||
}
|
||||
|
||||
// ========== Normalisation quantités ==========
|
||||
function normalizeQuantityText(q) {
|
||||
if (!q) return "";
|
||||
let s = String(q).toLowerCase();
|
||||
s = s.replace(/[½¼¾⅓⅔⅛⅜⅝⅞]/g, m => VULGAR_FRAC[m] || m);
|
||||
s = s.replace(/\b(\d+)\s*\/\s*(\d+)\b/g, (_, a, b) => {
|
||||
const num = parseFloat(a), den = parseFloat(b);
|
||||
return (!den || isNaN(num) || isNaN(den)) ? `${a}/${b}` : (num / den).toString();
|
||||
});
|
||||
s = s.replace(/,/g, ".").replace(/\s+/g, " ").trim();
|
||||
s = s
|
||||
.replace(/\b(pieces?)\b/g, "pièce")
|
||||
.replace(/\b(pcs?)\b/g, "pièce")
|
||||
.replace(/\b(gr|grammes?|grams?)\b/g, "g")
|
||||
.replace(/\b(millilitres?|milliliters?|mls?)\b/g, "ml")
|
||||
.replace(/\b(litres?|liters?)\b/g, "l");
|
||||
s = s
|
||||
.replace(/\b(c(?:uill[eè]re)?s?\s*(?:a|à)\s*s(?:oupe)?s?\.?)\b/gi, "cs")
|
||||
.replace(/\b(c(?:uill[eè]re)?s?\s*(?:a|à)\s*caf[eé]s?s?\.?)\b/gi, "cc");
|
||||
s = s.replace(/[\/]+/g, " ").trim();
|
||||
return s;
|
||||
}
|
||||
function parseQuantity(q) {
|
||||
const s = normalizeQuantityText(q);
|
||||
const m = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*([a-zéû]+)?$/i);
|
||||
if (!m) return { value: NaN, unit: "", raw: s };
|
||||
return { value: parseFloat(m[1]), unit: (m[2] || "").trim(), raw: s };
|
||||
}
|
||||
function quantityScore(q) {
|
||||
const { value, unit, raw } = parseQuantity(q);
|
||||
const isNum = !Number.isNaN(value);
|
||||
const unitRank = UNIT_RANK[unit] ?? (unit ? 2 : 1);
|
||||
return { isNum, unitRank, value: isNum ? value : -Infinity, raw };
|
||||
}
|
||||
function pickBestQuantity(qtys) {
|
||||
const arr = Array.from(qtys || []);
|
||||
if (arr.length === 0) return "";
|
||||
const dedup = Array.from(new Map(arr.map(q => [normalizeQuantityText(q), q])).values());
|
||||
dedup.sort((a, b) => {
|
||||
const sa = quantityScore(a), sb = quantityScore(b);
|
||||
if (sa.isNum !== sb.isNum) return sa.isNum ? -1 : 1;
|
||||
if (sa.unitRank !== sb.unitRank) return sb.unitRank - sa.unitRank;
|
||||
if (sa.value !== sb.value) return sb.value - sa.value;
|
||||
return sa.raw.localeCompare(sb.raw, "fr");
|
||||
});
|
||||
return normalizeQuantityText(dedup[0]);
|
||||
}
|
||||
|
||||
// ========== Ingrédients utils ==========
|
||||
function normalizeItem(it) {
|
||||
if (!it || typeof it !== "object") return { Name: "", Quantity: "", Image: "", Owned: false };
|
||||
return {
|
||||
Name: it.Name ?? it.name ?? "",
|
||||
Quantity: it.Quantity ?? it.quantity ?? "",
|
||||
Image: it.Image ?? it.image ?? "",
|
||||
Owned: Boolean(it.Owned ?? it.owned ?? false),
|
||||
};
|
||||
}
|
||||
function isOwned(name) { return OWNED_SET.has(normalizeName(name)); }
|
||||
function mergeDuplicates(items) {
|
||||
const map = new Map();
|
||||
for (const raw of items) {
|
||||
const it = normalizeItem(raw);
|
||||
if (!it.Name) continue;
|
||||
const key = normalizeName(it.Name);
|
||||
const prev = map.get(key);
|
||||
if (!prev) {
|
||||
map.set(key, { ...it, _qty: new Set(it.Quantity ? [it.Quantity] : []) });
|
||||
} else {
|
||||
if (!prev.Image && it.Image) prev.Image = it.Image;
|
||||
if (it.Quantity) prev._qty.add(it.Quantity);
|
||||
prev.Owned = prev.Owned || it.Owned;
|
||||
}
|
||||
}
|
||||
return Array.from(map.values()).map(x => ({ ...x, Quantity: pickBestQuantity(x._qty) }));
|
||||
}
|
||||
function sortIngredientsForDisplay(items) {
|
||||
return items.map(normalizeItem).filter(x => x.Name).sort((a, b) => {
|
||||
const ao = isOwned(a.Name) ? 1 : 0, bo = isOwned(b.Name) ? 1 : 0;
|
||||
if (ao !== bo) return ao - bo;
|
||||
return (a.Name || "").localeCompare((b.Name || ""), "fr", { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
// ========== UI cartes ingrédients ==========
|
||||
function renderIngredientCard(ingRaw) {
|
||||
const ing = normalizeItem(ingRaw);
|
||||
const div = document.createElement("div");
|
||||
div.className = "card";
|
||||
div.dataset.name = ing.Name;
|
||||
if (isOwned(ing.Name)) div.classList.add("checked");
|
||||
div.innerHTML = `
|
||||
<div class="image-container">
|
||||
<img src="${ing.Image || ""}" alt="${ing.Name}">
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h3>${ing.Name || "(ingrédient)"}</h3>
|
||||
<div class="footerCard">
|
||||
<div class="selection-slot"></div>
|
||||
<div class="recipe-info">
|
||||
<span class="prep-time">${ing.Quantity || ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
let busy = false;
|
||||
div.addEventListener("click", async () => {
|
||||
if (busy) return; busy = true;
|
||||
const name = div.dataset.name, key = normalizeName(name), was = div.classList.contains("checked");
|
||||
div.classList.toggle("checked");
|
||||
try {
|
||||
const res = await fetch("/HelloFresh/ToggleOwnedIngredient", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(name),
|
||||
});
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const { status } = await res.json();
|
||||
if (status === "added") OWNED_SET.add(key);
|
||||
else if (status === "removed") OWNED_SET.delete(key);
|
||||
else div.classList.toggle("checked", was);
|
||||
const idx = ALL_AGG_INGS.findIndex(x => normalizeName(x.Name) === key);
|
||||
if (idx !== -1) ALL_AGG_INGS[idx].Owned = (status === "added");
|
||||
} catch (e) {
|
||||
console.error("Erreur toggle ingrédient", e);
|
||||
div.classList.toggle("checked", was);
|
||||
} finally {
|
||||
busy = false; reapplyCurrentSearch();
|
||||
}
|
||||
});
|
||||
return div;
|
||||
}
|
||||
function renderItems(items) {
|
||||
if (!els.list) return;
|
||||
els.list.innerHTML = "";
|
||||
items.forEach(ing => els.list.appendChild(renderIngredientCard(ing)));
|
||||
}
|
||||
|
||||
// ========== Recherche ==========
|
||||
async function applyAISearch(q) {
|
||||
SEARCH_STATE.query = q; SEARCH_STATE.mode = q ? "ai" : null;
|
||||
if (!q?.trim()) { renderItems(sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]))); return; }
|
||||
if (els.list) els.list.innerHTML = "<em>Recherche IA…</em>";
|
||||
try {
|
||||
if (AI_CTRL) AI_CTRL.abort(); AI_CTRL = new AbortController();
|
||||
const res = await fetch("/HelloFresh/SmartSearch?debug=true", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: q }), signal: AI_CTRL.signal
|
||||
});
|
||||
const raw = await res.clone().text();
|
||||
if (!res.ok) { console.warn("AI SmartSearch HTTP", res.status, raw); applyLiveFilter(q); return; }
|
||||
const data = JSON.parse(raw);
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
if (!items.length) { applyLiveFilter(q); return; }
|
||||
renderItems(sortIngredientsForDisplay(mergeDuplicates(items)));
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") console.log("[AI] annulée");
|
||||
else { console.error("AI SmartSearch error:", err); applyLiveFilter(q); }
|
||||
} finally { AI_CTRL = null; }
|
||||
}
|
||||
function applyLiveFilter(q) {
|
||||
SEARCH_STATE.query = String(q ?? ""); SEARCH_STATE.mode = q ? "live" : null;
|
||||
const nq = normalizeText(q);
|
||||
if (!nq) { renderItems(sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]))); return; }
|
||||
const filtered = ALL_AGG_INGS.filter(x => normalizeText(x.Name).includes(nq));
|
||||
const list = sortIngredientsForDisplay(mergeDuplicates(applyExclusionsTo(filtered)));
|
||||
renderItems(list);
|
||||
}
|
||||
function reapplyCurrentSearch() {
|
||||
const q = SEARCH_STATE.query || "";
|
||||
if (!q) {
|
||||
const base = sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]));
|
||||
renderItems(applyExclusionsTo(base));
|
||||
return;
|
||||
}
|
||||
if (SEARCH_STATE.mode === "ai") applyAISearch(q); else applyLiveFilter(q);
|
||||
}
|
||||
|
||||
// ========== Chargement liste principale ==========
|
||||
async function loadAndRenderIngredients() {
|
||||
if (!els.list) return;
|
||||
els.list.innerHTML = "Chargement…";
|
||||
try {
|
||||
const res = await fetch("/HelloFresh/AggregatedIngredients");
|
||||
if (!res.ok) { const txt = await res.text().catch(() => ""); console.error("[AggregatedIngredients] HTTP", res.status, txt); els.list.innerHTML = `Erreur chargement ingrédients (${res.status})`; return; }
|
||||
const data = await res.json();
|
||||
ALL_AGG_INGS = Array.isArray(data) ? data : [];
|
||||
OWNED_SET = new Set(ALL_AGG_INGS.filter(x => x?.Name && x.Owned === true).map(x => x.Name.trim().toLowerCase()));
|
||||
els.list.innerHTML = "";
|
||||
const initialMerged = mergeDuplicates([...ALL_AGG_INGS]);
|
||||
const initial = sortIngredientsForDisplay(applyExclusionsTo(initialMerged));
|
||||
els.list.innerHTML = "";
|
||||
renderItems(initial.length ? initial : []);
|
||||
if (!initial.length) els.list.innerHTML = "<em>Aucun ingrédient</em>";
|
||||
} catch (e) { console.error("[AggregatedIngredients] exception", e); els.list.innerHTML = "Erreur chargement ingrédients"; }
|
||||
}
|
||||
|
||||
// ========== Not shipped (DOM) + BDD ==========
|
||||
function getNotShippedIngredientsFromDOM(root = document) {
|
||||
const nodes = Array.from(root.querySelectorAll(".ingredient-item-not-shipped"));
|
||||
return nodes.map(n => {
|
||||
const name = (n.querySelector('[data-test="ingredient-name"], .ingredient-name, .name, h3, span')?.textContent || n.getAttribute("data-name") || n.querySelector("img")?.alt || "").trim();
|
||||
const qty = (n.querySelector('[data-test="ingredient-quantity"], .ingredient-amount, .quantity, .qty')?.textContent || n.getAttribute("data-quantity") || "").trim();
|
||||
const img = (n.querySelector("img")?.src || n.getAttribute("data-img") || "").trim();
|
||||
return normalizeItem({ Name: name, Quantity: qty, Image: img, Owned: isOwned(name) });
|
||||
}).filter(x => x.Name);
|
||||
}
|
||||
// Détecte la propriété "ingredients à avoir" dans un objet recette,
|
||||
// même si le nom varie (ingredientsToHad / IngredientsToHad / pantry / etc.)
|
||||
function detectIngredientsToHadProp(recipe) {
|
||||
if (!recipe || typeof recipe !== "object") return null;
|
||||
|
||||
// Candidats fréquents (essayés d'abord)
|
||||
const CANDIDATES = [
|
||||
"ingredientsToHad", "IngredientsToHad", "ingredients_to_had",
|
||||
"ingredientsToHaveAtHome", "ingredientsToHave", "toHaveAtHome",
|
||||
"pantry", "pantryItems", "pantry_ingredients", "ingredientsPantry"
|
||||
];
|
||||
|
||||
for (const key of CANDIDATES) {
|
||||
if (key in recipe && recipe[key]) {
|
||||
try {
|
||||
const val = recipe[key];
|
||||
const obj = (typeof val === "string") ? JSON.parse(val) : val;
|
||||
if (obj && typeof obj === "object" &&
|
||||
(Array.isArray(obj["1"]) || Array.isArray(obj["2"]) || Array.isArray(obj["3"]) || Array.isArray(obj["4"]))) {
|
||||
return { prop: key, parsed: obj };
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristique : scanne toutes les props à la recherche d’un objet 1..4 -> array d’objets Name/Quantity
|
||||
for (const [key, val] of Object.entries(recipe)) {
|
||||
if (!val) continue;
|
||||
try {
|
||||
const obj = (typeof val === "string") ? JSON.parse(val) : val;
|
||||
if (obj && typeof obj === "object") {
|
||||
const arr = obj["1"] || obj["2"] || obj["3"] || obj["4"];
|
||||
if (Array.isArray(arr) && arr.length) {
|
||||
const it = arr[0] || {};
|
||||
if (typeof it === "object" && ("Name" in it || "name" in it)) {
|
||||
return { prop: key, parsed: obj };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lit une recette en tolérant la casse / formats
|
||||
function getIngredientsToHadFromRecipe(r) {
|
||||
const det = detectIngredientsToHadProp(r);
|
||||
if (!det) return [];
|
||||
const pRaw = r?.portions ?? r?.Portions ?? 0;
|
||||
const portions = clampQty(pRaw) + 1; // 1..4
|
||||
const list = Array.isArray(det.parsed[portions]) ? det.parsed[portions] : [];
|
||||
return list.map(normalizeItem);
|
||||
}
|
||||
|
||||
|
||||
function collectIngredientsToHadFromAllRecipes() {
|
||||
const res = [];
|
||||
const all = Array.isArray(window.allRecipes) ? window.allRecipes : [];
|
||||
for (const r of all) res.push(...getIngredientsToHadFromRecipe(r));
|
||||
return res;
|
||||
}
|
||||
|
||||
function computeSignature(items) {
|
||||
const merged = mergeDuplicates(items || []);
|
||||
return JSON.stringify(merged.map(i => ({ n: normalizeName(i.Name), q: normalizeQuantityText(i.Quantity), img: i.Image || "" })));
|
||||
}
|
||||
|
||||
function renderNeededList(itemsArr) {
|
||||
const container = document.getElementById("neededIngredients");
|
||||
if (!container) return;
|
||||
const merged = mergeDuplicates(itemsArr || []);
|
||||
const sig = computeSignature(merged);
|
||||
if (sig === lastNeededSignature) return;
|
||||
lastNeededSignature = sig;
|
||||
container.innerHTML = "";
|
||||
for (const ing of merged) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "card needed";
|
||||
li.innerHTML = `
|
||||
<div class="image-container"><img src="${ing.Image || ""}" alt="${ing.Name}"></div>
|
||||
<div class="card-content">
|
||||
<h3>${ing.Name || "(ingrédient)"}</h3>
|
||||
<div class="footerCard"><div class="recipe-info"></div></div>
|
||||
</div>`;
|
||||
container.appendChild(li);
|
||||
}
|
||||
}
|
||||
function updateNeededList(trigger = "manual") {
|
||||
const container = document.getElementById("neededIngredients");
|
||||
if (!container) {
|
||||
console.debug("[needed]", trigger, "→ container #neededIngredients introuvable (on réessaiera)");
|
||||
return;
|
||||
}
|
||||
console.debug("[needed]", trigger, "→ container OK");
|
||||
|
||||
// 1) DOM (non livrés)
|
||||
const domItems = getNotShippedIngredientsFromDOM();
|
||||
if (domItems.length) {
|
||||
const filtered = applyNeededExclusions(domItems);
|
||||
renderNeededList(filtered);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Agrégation de TOUTES les recettes
|
||||
const aggregated = collectIngredientsToHadFromAllRecipes();
|
||||
if (aggregated.length) {
|
||||
const filtered = applyNeededExclusions(aggregated);
|
||||
renderNeededList(filtered);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Dernière recette vue (fallback)
|
||||
if (LAST_ING_TO_HAD) {
|
||||
try {
|
||||
const parsed = JSON.parse(LAST_ING_TO_HAD || "{}");
|
||||
const list = Array.isArray(parsed[LAST_PORTIONS]) ? parsed[LAST_PORTIONS] : [];
|
||||
const filtered = applyNeededExclusions(list);
|
||||
if (filtered.length) {
|
||||
renderNeededList(filtered);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* ... */ }
|
||||
}
|
||||
|
||||
|
||||
// 4) Rien
|
||||
const emptySig = "EMPTY";
|
||||
if (lastNeededSignature !== emptySig) {
|
||||
lastNeededSignature = emptySig;
|
||||
container.innerHTML = "<p>Aucun ingrédient à afficher.</p>";
|
||||
}
|
||||
console.warn("[needed]", trigger, "→ aucun item");
|
||||
}
|
||||
|
||||
// Scan DOM non-livrés (anti-boucle)
|
||||
function scanNotShippedAndUpdate() {
|
||||
const count = document.querySelectorAll(".ingredient-item-not-shipped").length;
|
||||
if (count !== prevNotShippedCount) { prevNotShippedCount = count; updateNeededList(); }
|
||||
}
|
||||
|
||||
// ========== Recettes (strip) ==========
|
||||
const ownedMap = new Map();
|
||||
const MAX_PORTIONS = 14;
|
||||
const clampQty = n => Math.max(0, Math.min(3, Number(n) || 0));
|
||||
const truncate = (s, m) => (s && s.length > m ? s.slice(0, m) + "…" : (s ?? ""));
|
||||
|
||||
function setSelectedUI(card, selected) {
|
||||
const slot = card?.querySelector?.(".selection-slot"); if (!slot) return;
|
||||
if (selected) {
|
||||
slot.classList.add("active");
|
||||
if (!slot.querySelector(".selection-indicator")) {
|
||||
slot.innerHTML = `<div class="selection-indicator"><span class="selection-text">Sélectionné</span></div>`;
|
||||
}
|
||||
} else { slot.classList.remove("active"); slot.innerHTML = ""; }
|
||||
}
|
||||
function applySelectionUIById(id) {
|
||||
const card = document.getElementById(String(id));
|
||||
const selected = (ownedMap.get(String(id)) || 0) > 0;
|
||||
setSelectedUI(card, selected);
|
||||
}
|
||||
|
||||
function buildLabels(tagsStr) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "label-container";
|
||||
const tags = (tagsStr || "").split(/[,•]/).map(t => t.trim()).filter(Boolean).filter(t => t !== "•" && t !== "\u2022");
|
||||
tags.forEach(tag => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "label-badge";
|
||||
const c = (window.labelColors || {})[tag];
|
||||
span.style.backgroundColor = c?.bg ?? "#eee";
|
||||
span.style.color = c?.color ?? "#333";
|
||||
span.textContent = tag;
|
||||
wrap.appendChild(span);
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildRecipeCardList(recipe) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card";
|
||||
card.id = recipe.id ?? recipe.Id;
|
||||
card.innerHTML = `
|
||||
<div class="image-container"><img src="${recipe.image}" alt="${truncate(recipe.name, 10)}"></div>
|
||||
<div class="card-content">
|
||||
<h3>${recipe.name}</h3>
|
||||
<div class="footerCard"><div class="recipe-info"><span class="prep-time">${recipe.tempsDePreparation}min</span></div></div>
|
||||
</div>
|
||||
`.replace(/\${/g, "${");
|
||||
card.addEventListener("click", async (e) => {
|
||||
if (e.ctrlKey) return; // laisse le Ctrl+clic tranquille
|
||||
|
||||
const id = recipe.id ?? recipe.Id;
|
||||
const portions = clampQty(recipe.portions ?? recipe.Portions ?? 0) + 1; // 1..4
|
||||
|
||||
if (EXCLUDED_BY_RECIPE.has(String(id))) {
|
||||
// ré-afficher
|
||||
EXCLUDED_BY_RECIPE.delete(String(id));
|
||||
EXCLUDED_NEEDED_BY_RECIPE.delete(String(id)); // 👈 aussi pour "à avoir"
|
||||
card.classList.remove("card--excluded");
|
||||
} else {
|
||||
// cacher
|
||||
const namesMain = await getIngredientNamesForRecipe(id, portions); // déjà en place chez toi
|
||||
EXCLUDED_BY_RECIPE.set(String(id), new Set(namesMain.map(nameKey)));
|
||||
|
||||
const namesNeeded = getNeededNamesForRecipeObj(recipe, portions); // 👈 "à avoir" depuis l’objet recette
|
||||
EXCLUDED_NEEDED_BY_RECIPE.set(String(id), new Set(namesNeeded.map(nameKey)));
|
||||
|
||||
card.classList.add("card--excluded");
|
||||
}
|
||||
|
||||
// Re-rendre les deux panneaux
|
||||
reapplyCurrentSearch(); // liste principale
|
||||
updateNeededList("exclude-toggle"); // “à avoir”
|
||||
});
|
||||
|
||||
|
||||
const usersVal = recipe.Users ?? recipe.users ?? recipe.User ?? recipe.user ?? [];
|
||||
const usersArr = Array.isArray(usersVal) ? usersVal.filter(Boolean) : String(usersVal || "").split(/[,•]/).map(s => s.trim()).filter(Boolean);
|
||||
if (usersArr.length) {
|
||||
const img = card.querySelector(".image-container");
|
||||
const wrap = document.createElement("div"); wrap.className = "label-container";
|
||||
usersArr.forEach(u => { const span = document.createElement("span"); span.className = "label-badge user-badge"; span.textContent = u; wrap.appendChild(span); });
|
||||
img?.appendChild(wrap);
|
||||
}
|
||||
|
||||
// Mémorise la dernière recette pour un fallback, via détection auto
|
||||
const det = detectIngredientsToHadProp(recipe);
|
||||
const portions = clampQty(recipe.portions ?? recipe.Portions ?? 0) + 1;
|
||||
LAST_PORTIONS = portions;
|
||||
LAST_ING_TO_HAD = det ? JSON.stringify(det.parsed) : null;
|
||||
|
||||
console.debug("[needed] buildRecipe detected prop:", det?.prop || "(none)", "portions:", portions);
|
||||
updateNeededList("buildRecipe");
|
||||
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
const GET_ALL_RECIPES_URL = "/HelloFresh/GetAllRecipes";
|
||||
async function loadAllRecipesHorizontal() {
|
||||
const strip = document.getElementById("recipeStrip");
|
||||
if (!strip) return;
|
||||
|
||||
try {
|
||||
const r = await fetch("/HelloFresh/GetRecipesOwned?isUserImportant=false", { credentials: "same-origin" });
|
||||
if (r.ok) { const data = await r.json(); ownedMap.clear(); (data || []).forEach(x => ownedMap.set(String(x.id), clampQty(x.portions || 0))); }
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const res = await fetch(GET_ALL_RECIPES_URL, { credentials: "same-origin" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const all = await res.json();
|
||||
window.allRecipes = all;
|
||||
|
||||
strip.innerHTML = "";
|
||||
if (!Array.isArray(all) || all.length === 0) { strip.innerHTML = '<p class="placeholder">Aucune recette disponible.</p>'; updateNeededList(); return; }
|
||||
|
||||
all.sort((a, b) => {
|
||||
const ua = String(a.userId ?? ""), ub = String(b.userId ?? "");
|
||||
if (ua !== ub) return ua.localeCompare(ub);
|
||||
return String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" });
|
||||
});
|
||||
|
||||
all.forEach(r => strip.appendChild(buildRecipeCardList(r)));
|
||||
updateNeededList("afterGetAllRecipes");
|
||||
console.debug("[needed] recipes loaded:", Array.isArray(window.allRecipes) ? window.allRecipes.length : 0);
|
||||
|
||||
} catch (e) {
|
||||
console.error("loadAllRecipesHorizontal error", e);
|
||||
strip.innerHTML = '<p class="placeholder">Erreur de chargement.</p>';
|
||||
updateNeededList();
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Init
|
||||
// ======================
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
els.list = document.getElementById("ingredientsList") || null;
|
||||
els.search = document.getElementById("hfSearch") || null;
|
||||
|
||||
document.querySelectorAll(".dropdown-toggle").forEach(el => el.addEventListener("click", (e) => e.preventDefault()));
|
||||
|
||||
if (els.search) {
|
||||
let aiTimer;
|
||||
els.search.addEventListener("input", (e) => { clearTimeout(aiTimer); const val = e.target.value || ""; aiTimer = setTimeout(() => applyAISearch(val), AI_DELAY); });
|
||||
els.search.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); clearTimeout(aiTimer); applyAISearch(els.search.value || ""); } });
|
||||
} else {
|
||||
console.error("[ingredients] #hfSearch introuvable dans le DOM");
|
||||
}
|
||||
|
||||
loadAndRenderIngredients();
|
||||
loadAllRecipesHorizontal();
|
||||
|
||||
// Premier rendu (même si le UL n'est pas encore dans le DOM, on loggue)
|
||||
updateNeededList("dom-ready");
|
||||
|
||||
// Petits réessaies pour les pages qui injectent tardivement le UL / les cartes
|
||||
setTimeout(() => updateNeededList("post-300ms"), 300);
|
||||
setTimeout(() => updateNeededList("post-1200ms"), 1200);
|
||||
const mo = new MutationObserver(() => {
|
||||
// Throttle : on regroupe les mutations
|
||||
if (typeof window !== "undefined") {
|
||||
if (mo._scheduled) return;
|
||||
mo._scheduled = true;
|
||||
setTimeout(() => {
|
||||
mo._scheduled = false;
|
||||
updateNeededList("mutation");
|
||||
}, 120);
|
||||
} else {
|
||||
updateNeededList("mutation");
|
||||
}
|
||||
});
|
||||
mo.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
});
|
||||
8
wwwroot/js/HelloFresh/tagsColor.js
Normal file
8
wwwroot/js/HelloFresh/tagsColor.js
Normal file
@@ -0,0 +1,8 @@
|
||||
window.labelColors = {
|
||||
"À faire rapidement": { bg: "#e65100", color: "#fff" },
|
||||
"Calorie Smart": { bg: "#00838f", color: "#fff" },
|
||||
"Équilibre": { bg: "#2e7d32", color: "#fff" },
|
||||
"Faible en calories": { bg: "#f9a825", color: "#fff" },
|
||||
"Végétarien": { bg: "#6a1b9a", color: "#fff" },
|
||||
"•": { bg: "#424242", color: "#fff" }
|
||||
};
|
||||
@@ -3,7 +3,8 @@ const API_PASSWORD = 'Mf33ksTRLrPKSqQ4cTXitgiSN6BPBt89';
|
||||
|
||||
const Controller = {
|
||||
Revenue: "revenue",
|
||||
Expense: "expense"
|
||||
Expense: "expense",
|
||||
HelloFresh: "hellofresh"
|
||||
}
|
||||
function getApiBaseUrl() {
|
||||
const isLocal = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
// Set new default font family and font color to mimic Bootstrap's default styling
|
||||
Chart.defaults.global.defaultFontFamily = 'Nunito', '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
Chart.defaults.global.defaultFontColor = '#858796';
|
||||
|
||||
function number_format(number, decimals, dec_point, thousands_sep) {
|
||||
// * example: number_format(1234.56, 2, ',', ' ');
|
||||
// * return: '1 234,56'
|
||||
number = (number + '').replace(',', '').replace(' ', '');
|
||||
var n = !isFinite(+number) ? 0 : +number,
|
||||
prec = !isFinite(+decimals) ? 0 : Math.abs(decimals),
|
||||
sep = (typeof thousands_sep === 'undefined') ? ',' : thousands_sep,
|
||||
dec = (typeof dec_point === 'undefined') ? '.' : dec_point,
|
||||
s = '',
|
||||
toFixedFix = function(n, prec) {
|
||||
var k = Math.pow(10, prec);
|
||||
return '' + Math.round(n * k) / k;
|
||||
};
|
||||
// Fix for IE parseFloat(0.55).toFixed(0) = 0;
|
||||
s = (prec ? toFixedFix(n, prec) : '' + Math.round(n)).split('.');
|
||||
if (s[0].length > 3) {
|
||||
s[0] = s[0].replace(/\B(?=(?:\d{3})+(?!\d))/g, sep);
|
||||
}
|
||||
if ((s[1] || '').length < prec) {
|
||||
s[1] = s[1] || '';
|
||||
s[1] += new Array(prec - s[1].length + 1).join('0');
|
||||
}
|
||||
return s.join(dec);
|
||||
}
|
||||
|
||||
// Area Chart Example
|
||||
var ctx = document.getElementById("myAreaChart");
|
||||
var myLineChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
datasets: [{
|
||||
label: "Earnings",
|
||||
lineTension: 0.3,
|
||||
backgroundColor: "rgba(78, 115, 223, 0.05)",
|
||||
borderColor: "rgba(78, 115, 223, 1)",
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: "rgba(78, 115, 223, 1)",
|
||||
pointBorderColor: "rgba(78, 115, 223, 1)",
|
||||
pointHoverRadius: 3,
|
||||
pointHoverBackgroundColor: "rgba(78, 115, 223, 1)",
|
||||
pointHoverBorderColor: "rgba(78, 115, 223, 1)",
|
||||
pointHitRadius: 10,
|
||||
pointBorderWidth: 2,
|
||||
data: [0, 10000, 5000, 15000, 10000, 20000, 15000, 25000, 20000, 30000, 25000, 40000],
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 10,
|
||||
right: 25,
|
||||
top: 25,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
time: {
|
||||
unit: 'date'
|
||||
},
|
||||
gridLines: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
maxTicksLimit: 7
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
maxTicksLimit: 5,
|
||||
padding: 10,
|
||||
// Include a dollar sign in the ticks
|
||||
callback: function(value, index, values) {
|
||||
return '$' + number_format(value);
|
||||
}
|
||||
},
|
||||
gridLines: {
|
||||
color: "rgb(234, 236, 244)",
|
||||
zeroLineColor: "rgb(234, 236, 244)",
|
||||
drawBorder: false,
|
||||
borderDash: [2],
|
||||
zeroLineBorderDash: [2]
|
||||
}
|
||||
}],
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltips: {
|
||||
backgroundColor: "rgb(255,255,255)",
|
||||
bodyFontColor: "#858796",
|
||||
titleMarginBottom: 10,
|
||||
titleFontColor: '#6e707e',
|
||||
titleFontSize: 14,
|
||||
borderColor: '#dddfeb',
|
||||
borderWidth: 1,
|
||||
xPadding: 15,
|
||||
yPadding: 15,
|
||||
displayColors: false,
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
caretPadding: 10,
|
||||
callbacks: {
|
||||
label: function(tooltipItem, chart) {
|
||||
var datasetLabel = chart.datasets[tooltipItem.datasetIndex].label || '';
|
||||
return datasetLabel + ': $' + number_format(tooltipItem.yLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
// Set new default font family and font color to mimic Bootstrap's default styling
|
||||
Chart.defaults.global.defaultFontFamily = 'Nunito', '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
Chart.defaults.global.defaultFontColor = '#858796';
|
||||
|
||||
function number_format(number, decimals, dec_point, thousands_sep) {
|
||||
// * example: number_format(1234.56, 2, ',', ' ');
|
||||
// * return: '1 234,56'
|
||||
number = (number + '').replace(',', '').replace(' ', '');
|
||||
var n = !isFinite(+number) ? 0 : +number,
|
||||
prec = !isFinite(+decimals) ? 0 : Math.abs(decimals),
|
||||
sep = (typeof thousands_sep === 'undefined') ? ',' : thousands_sep,
|
||||
dec = (typeof dec_point === 'undefined') ? '.' : dec_point,
|
||||
s = '',
|
||||
toFixedFix = function(n, prec) {
|
||||
var k = Math.pow(10, prec);
|
||||
return '' + Math.round(n * k) / k;
|
||||
};
|
||||
// Fix for IE parseFloat(0.55).toFixed(0) = 0;
|
||||
s = (prec ? toFixedFix(n, prec) : '' + Math.round(n)).split('.');
|
||||
if (s[0].length > 3) {
|
||||
s[0] = s[0].replace(/\B(?=(?:\d{3})+(?!\d))/g, sep);
|
||||
}
|
||||
if ((s[1] || '').length < prec) {
|
||||
s[1] = s[1] || '';
|
||||
s[1] += new Array(prec - s[1].length + 1).join('0');
|
||||
}
|
||||
return s.join(dec);
|
||||
}
|
||||
|
||||
// Bar Chart Example
|
||||
var ctx = document.getElementById("myBarChart");
|
||||
var myBarChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ["January", "February", "March", "April", "May", "June"],
|
||||
datasets: [{
|
||||
label: "Revenue",
|
||||
backgroundColor: "#4e73df",
|
||||
hoverBackgroundColor: "#2e59d9",
|
||||
borderColor: "#4e73df",
|
||||
data: [4215, 5312, 6251, 7841, 9821, 14984],
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 10,
|
||||
right: 25,
|
||||
top: 25,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
time: {
|
||||
unit: 'month'
|
||||
},
|
||||
gridLines: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
maxTicksLimit: 6
|
||||
},
|
||||
maxBarThickness: 25,
|
||||
}],
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
min: 0,
|
||||
max: 15000,
|
||||
maxTicksLimit: 5,
|
||||
padding: 10,
|
||||
// Include a dollar sign in the ticks
|
||||
callback: function(value, index, values) {
|
||||
return '$' + number_format(value);
|
||||
}
|
||||
},
|
||||
gridLines: {
|
||||
color: "rgb(234, 236, 244)",
|
||||
zeroLineColor: "rgb(234, 236, 244)",
|
||||
drawBorder: false,
|
||||
borderDash: [2],
|
||||
zeroLineBorderDash: [2]
|
||||
}
|
||||
}],
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltips: {
|
||||
titleMarginBottom: 10,
|
||||
titleFontColor: '#6e707e',
|
||||
titleFontSize: 14,
|
||||
backgroundColor: "rgb(255,255,255)",
|
||||
bodyFontColor: "#858796",
|
||||
borderColor: '#dddfeb',
|
||||
borderWidth: 1,
|
||||
xPadding: 15,
|
||||
yPadding: 15,
|
||||
displayColors: false,
|
||||
caretPadding: 10,
|
||||
callbacks: {
|
||||
label: function(tooltipItem, chart) {
|
||||
var datasetLabel = chart.datasets[tooltipItem.datasetIndex].label || '';
|
||||
return datasetLabel + ': $' + number_format(tooltipItem.yLabel);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
// Set new default font family and font color to mimic Bootstrap's default styling
|
||||
Chart.defaults.global.defaultFontFamily = 'Nunito', '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
Chart.defaults.global.defaultFontColor = '#858796';
|
||||
|
||||
// Pie Chart Example
|
||||
var ctx = document.getElementById("myPieChart");
|
||||
var myPieChart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ["Direct", "Referral", "Social"],
|
||||
datasets: [{
|
||||
data: [55, 30, 15],
|
||||
backgroundColor: ['#4e73df', '#1cc88a', '#36b9cc'],
|
||||
hoverBackgroundColor: ['#2e59d9', '#17a673', '#2c9faf'],
|
||||
hoverBorderColor: "rgba(234, 236, 244, 1)",
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
tooltips: {
|
||||
backgroundColor: "rgb(255,255,255)",
|
||||
bodyFontColor: "#858796",
|
||||
borderColor: '#dddfeb',
|
||||
borderWidth: 1,
|
||||
xPadding: 15,
|
||||
yPadding: 15,
|
||||
displayColors: false,
|
||||
caretPadding: 10,
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
cutoutPercentage: 80,
|
||||
},
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
// Call the dataTables jQuery plugin
|
||||
$(document).ready(function() {
|
||||
$('#dataTable').DataTable();
|
||||
});
|
||||
5
wwwroot/js/home.js
Normal file
5
wwwroot/js/home.js
Normal file
@@ -0,0 +1,5 @@
|
||||
document.querySelectorAll('.dropdown .dropdown-toggle').forEach(toggle => {
|
||||
toggle.addEventListener('click', () => {
|
||||
toggle.parentElement.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Exemple d’utilisation :
|
||||
* Récupère les loyers avec un nombre de lignes défini
|
||||
* @param {number} rows - Nombre de lignes à récupérer
|
||||
*/
|
||||
|
||||
const Revenue = {
|
||||
GetXRevenues: "x_revenues",
|
||||
GetLastRevenues: "last_revenue",
|
||||
GetAllRevenues: "all_revenues",
|
||||
GetAdditionalRevenues: "additional_revenues"
|
||||
};
|
||||
|
||||
const Expense = {
|
||||
GetRightExpenses: "last_expenses"
|
||||
}
|
||||
|
||||
var TotalRevenu = 0, TotalExpense = 0;
|
||||
|
||||
function loadRevenues() {
|
||||
return Promise.all([
|
||||
new Promise((resolve, reject) => {
|
||||
apiCall(Controller.Revenue, Revenue.GetXRevenues, { rows: 1 }, data => {
|
||||
const loyer = document.getElementById('salaire');
|
||||
const salaire = parseFloat(data[0]?.salary || 0);
|
||||
if (loyer) loyer.innerText = `${toPriceFormat(salaire)}€`;
|
||||
resolve(salaire);
|
||||
}, reject);
|
||||
}),
|
||||
new Promise((resolve, reject) => {
|
||||
apiCall(Controller.Revenue, Revenue.GetAdditionalRevenues, {}, data => {
|
||||
const additionalRevenues = document.getElementById('additionalRevenues');
|
||||
const totalAR = data.reduce((acc, item) => acc + (parseFloat(item.amount) || 0), 0);
|
||||
if (additionalRevenues) additionalRevenues.innerText = `${toPriceFormat(totalAR)}€`;
|
||||
resolve(totalAR);
|
||||
}, reject);
|
||||
})
|
||||
]).then(([salary, additional]) => {
|
||||
TotalRevenu = salary + additional;
|
||||
});
|
||||
}
|
||||
|
||||
function loadExpenses() {
|
||||
return new Promise((resolve, reject) => {
|
||||
apiCall(Controller.Expense, Expense.GetRightExpenses, {}, data => {
|
||||
const loyer = document.getElementById('loyer');
|
||||
const trash = document.getElementById('trash');
|
||||
const electricity = document.getElementById('electricity');
|
||||
const insurance = document.getElementById('insurance');
|
||||
const wifi = document.getElementById('wifi');
|
||||
const groceries = document.getElementById('groceries');
|
||||
const additionalSourcesExpense = document.getElementById('additionalSourcesExpense');
|
||||
const additionalExpensesSub = document.getElementById('additionalExpensesSub');
|
||||
const saving = document.getElementById('saving');
|
||||
|
||||
if (loyer) loyer.innerText = `${toPriceFormat(data.rent)}€`;
|
||||
if (trash) trash.innerText = `${toPriceFormat(data.trash)}€`;
|
||||
if (insurance) insurance.innerText = `${toPriceFormat(data.insurance)}€`;
|
||||
if (electricity) electricity.innerText = `${toPriceFormat(data.electricity)}€`;
|
||||
if (wifi) wifi.innerText = `${toPriceFormat(data.wifi)}€`;
|
||||
if (groceries) groceries.innerText = `${toPriceFormat(data.groceries)}€`;
|
||||
if (saving) saving.innerText = `${toPriceFormat(data.saving)}€`;
|
||||
|
||||
if (additionalSourcesExpense) {
|
||||
const totalAR = data.additionalSourcesExpense.reduce((acc, item) => acc + (parseFloat(item.amount) || 0), 0);
|
||||
additionalSourcesExpense.innerText = `${toPriceFormat(totalAR)}€`;
|
||||
}
|
||||
|
||||
if (additionalExpensesSub) {
|
||||
const totalAR = data.additionalSourcesSub.reduce((acc, item) => acc + (parseFloat(item.amount) || 0), 0);
|
||||
additionalExpensesSub.innerText = `${toPriceFormat(totalAR)}€`;
|
||||
}
|
||||
|
||||
TotalExpense = data.total;
|
||||
resolve();
|
||||
}, reject);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Lance automatiquement l’appel au chargement de la page
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const result = document.getElementById('result');
|
||||
|
||||
Promise.all([loadExpenses(), loadRevenues()])
|
||||
.then(() => {
|
||||
if (result) result.innerText = `${toPriceFormat(TotalRevenu - TotalExpense)}€`;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("❌ Erreur de chargement :", error);
|
||||
if (result) result.innerText = "Erreur lors du calcul";
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,3 +75,14 @@ function toPriceFormat(valeur) {
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
|
||||
const video = document.getElementById("bg-video");
|
||||
video.playbackRate = 0.5; // ralenti
|
||||
let reverse = false;
|
||||
|
||||
video.addEventListener("ended", () => {
|
||||
reverse = !reverse;
|
||||
video.playbackRate = reverse ? -1 : 1; // négatif = lecture à l'envers
|
||||
video.currentTime = reverse ? video.duration : 0; // repartir du bon côté
|
||||
video.play();
|
||||
});
|
||||
Reference in New Issue
Block a user