Extract ingredientCard

This commit is contained in:
2026-04-10 14:01:54 +02:00
parent 12369465d7
commit 2883dc858e
4 changed files with 544 additions and 620 deletions

View File

@@ -1,4 +1,4 @@
import { INGREDIENTS, RECIPES, PRODUCTS, CATEGORY_LABELS, getProductsForIngredient } from '../data/catalog.js?v=8';
import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=8';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import {
addDays,
@@ -15,8 +15,7 @@ import {
savePlans,
} from '../services/planStore.js?v=2';
import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4';
import { loadPantry, getPantryTotal, getPantryProducts, setPantryQty, setPantryProductQty, addOrMergeShoppingLines } from '../services/pantryShopping.js?v=2';
import { showAppToast } from './toast.js';
import { loadPantry } from '../services/pantryShopping.js?v=2';
import {
bindCalendarDayClicks,
createCalendarTopbarHTML,
@@ -27,6 +26,7 @@ import {
renderCalendarGrid,
syncCalendarTodayButton,
} from './mealCalendar.js?v=1';
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260410-99';
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -34,207 +34,6 @@ function esc(s) {
const slotLabel = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
/* ── Product Card Popup ────────────────────────────── */
function getProductCardHTML() {
return `
<div id="mpe-product-card-overlay" class="fixed inset-0 z-[70] bg-black/50 hidden flex items-center justify-center p-6" style="pointer-events:none">
<div id="mpe-product-card" class="relative w-full max-w-xs bg-[#2d2e2b] rounded-2xl shadow-2xl overflow-hidden" style="pointer-events:auto; max-height:80vh; overflow-y:auto;">
<div id="mpe-pc-hero" class="relative w-full h-[180px] bg-gray-800 overflow-hidden">
<img id="mpe-pc-img" class="w-full h-full object-cover hidden" alt="" />
<div id="mpe-pc-fallback" class="w-full h-full flex items-center justify-center">
<i class="fas fa-box-open text-3xl text-gray-600"></i>
</div>
<button type="button" id="mpe-pc-close" class="absolute top-3 right-3 w-8 h-8 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70 transition-colors">
<i class="fas fa-times text-sm"></i>
</button>
</div>
<div class="px-4 pt-3 pb-4 space-y-3">
<div>
<p id="mpe-pc-brand" class="text-[10px] font-semibold uppercase tracking-wider text-emerald-400"></p>
<h3 id="mpe-pc-name" class="text-[15px] font-bold text-gray-100 leading-snug mt-0.5"></h3>
<p id="mpe-pc-category" class="text-[11px] text-gray-500 mt-0.5"></p>
</div>
<div id="mpe-pc-pack" class="hidden">
<span class="text-[10px] font-semibold uppercase tracking-wider text-gray-500">Opakowanie</span>
<p id="mpe-pc-pack-val" class="text-[13px] font-semibold text-gray-300 mt-0.5"></p>
</div>
<div>
<span id="mpe-pc-nut-label" class="text-[10px] font-semibold uppercase tracking-wider text-gray-500">Wartości odżywcze na 100 g</span>
<div class="grid grid-cols-4 gap-2 mt-1.5">
<div class="bg-[#393937] rounded-xl px-2.5 py-2 text-center">
<p id="mpe-pc-kcal" class="text-[15px] font-bold text-gray-100 tabular-nums"></p>
<p class="text-[9px] text-gray-500 font-medium mt-0.5">kcal</p>
</div>
<div class="bg-[#393937] rounded-xl px-2.5 py-2 text-center">
<p id="mpe-pc-protein" class="text-[15px] font-bold text-blue-400 tabular-nums"></p>
<p class="text-[9px] text-gray-500 font-medium mt-0.5">białko</p>
</div>
<div class="bg-[#393937] rounded-xl px-2.5 py-2 text-center">
<p id="mpe-pc-fat" class="text-[15px] font-bold text-amber-400 tabular-nums"></p>
<p class="text-[9px] text-gray-500 font-medium mt-0.5">tłuszcz</p>
</div>
<div class="bg-[#393937] rounded-xl px-2.5 py-2 text-center">
<p id="mpe-pc-carbs" class="text-[15px] font-bold text-orange-400 tabular-nums"></p>
<p class="text-[9px] text-gray-500 font-medium mt-0.5">węgl.</p>
</div>
</div>
</div>
<div id="mpe-pc-stock" class="space-y-1.5"></div>
<div id="mpe-pc-shop"></div>
</div>
</div>
</div>`;
}
function openProductCard(ingredientId, productId) {
const overlay = document.getElementById('mpe-product-card-overlay');
if (!overlay) return;
const def = INGREDIENTS[ingredientId];
const product = productId ? PRODUCTS[productId] : null;
const name = product?.name || def?.name || ingredientId;
const brand = product?.brand || '';
const category = CATEGORY_LABELS[def?.category] || '';
const nutrition = product?.nutritionPer100g || def?.nutritionPer100g;
const image = product?.image || def?.image;
const packLabel = product?.packLabel || def?.purchasePack?.label || '';
const nutUnit = def?.pantryUnit === 'ml' ? '100 ml' : '100 g';
document.getElementById('mpe-pc-name').textContent = name;
document.getElementById('mpe-pc-brand').textContent = brand;
document.getElementById('mpe-pc-category').textContent = category;
document.getElementById('mpe-pc-nut-label').textContent = `Wartości odżywcze na ${nutUnit}`;
const img = document.getElementById('mpe-pc-img');
const fallback = document.getElementById('mpe-pc-fallback');
if (image) {
img.src = image;
img.alt = name;
img.classList.remove('hidden');
fallback.classList.add('hidden');
} else {
img.classList.add('hidden');
fallback.classList.remove('hidden');
}
const packWrap = document.getElementById('mpe-pc-pack');
if (packLabel) {
packWrap.classList.remove('hidden');
document.getElementById('mpe-pc-pack-val').textContent = packLabel;
} else {
packWrap.classList.add('hidden');
}
if (nutrition) {
document.getElementById('mpe-pc-kcal').textContent = nutrition.kcal;
document.getElementById('mpe-pc-protein').textContent = nutrition.protein + 'g';
document.getElementById('mpe-pc-fat').textContent = nutrition.fat + 'g';
document.getElementById('mpe-pc-carbs').textContent = nutrition.carbs + 'g';
}
// Stock section
renderPlannerCardStock(ingredientId, productId);
// Shop section
renderPlannerCardShop(ingredientId, productId);
overlay.classList.remove('hidden');
overlay.style.pointerEvents = 'auto';
}
function renderPlannerCardStock(ingredientId, productId) {
const wrap = document.getElementById('mpe-pc-stock');
if (!wrap) return;
const def = INGREDIENTS[ingredientId];
if (!def) return;
const u = def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit;
const pantry = loadPantry();
const product = productId ? PRODUCTS[productId] : null;
let qty, step, pid;
if (product) {
const items = getPantryProducts(ingredientId, pantry);
qty = items.find(i => i.productId === productId)?.qty || 0;
step = product.packSize || 1;
pid = productId;
} else {
qty = getPantryTotal(ingredientId, pantry);
step = def.purchasePack?.amount || (def.pantryUnit === 'szt' ? 1 : 10);
pid = '_generic';
}
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5 text-gray-500">Zapas</p>
<div class="flex items-center justify-center gap-3 rounded-xl px-3 py-2 bg-[#393937]">
<button type="button" class="mpe-pc-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95 bg-[#2f2f2d] text-[#d7d2c8]" data-pid="${esc(pid)}" data-step="${step}" data-dir="-1"><i class="fas fa-minus text-xs"></i></button>
<span class="text-[17px] font-bold tabular-nums" style="color:#6ee7b7;">${Math.round(qty)} ${esc(u)}</span>
<button type="button" class="mpe-pc-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95 bg-[#2f2f2d] text-[#d7d2c8]" data-pid="${esc(pid)}" data-step="${step}" data-dir="1"><i class="fas fa-plus text-xs"></i></button>
</div>`;
wrap.querySelectorAll('.mpe-pc-stock-btn').forEach(btn => {
btn.addEventListener('click', () => {
const bpid = btn.dataset.pid;
const bstep = Number(btn.dataset.step) || 1;
const dir = Number(btn.dataset.dir);
const p = loadPantry();
if (bpid === '_generic') {
const cur = getPantryTotal(ingredientId, p);
setPantryQty(ingredientId, Math.max(0, cur + bstep * dir));
} else {
const items = getPantryProducts(ingredientId, p);
const cur = items.find(i => i.productId === bpid)?.qty || 0;
setPantryProductQty(ingredientId, bpid, Math.max(0, cur + bstep * dir));
}
renderPlannerCardStock(ingredientId, productId);
});
});
}
function renderPlannerCardShop(ingredientId, productId) {
const wrap = document.getElementById('mpe-pc-shop');
if (!wrap) return;
const def = INGREDIENTS[ingredientId];
if (!def) return;
const u = def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit;
const product = productId ? PRODUCTS[productId] : null;
const packSize = product?.packSize || def.purchasePack?.amount;
const packLabel = product?.packLabel || def.purchasePack?.label;
const usesPacks = Boolean(packSize && packSize > 0);
const btnLabel = usesPacks ? `Dodaj na listę (${packLabel || `${packSize} ${u}`})` : 'Dodaj na listę';
wrap.innerHTML = `
<button type="button" id="mpe-pc-add-list" class="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-[13px] font-semibold transition-colors active:scale-[0.98]" style="background:#ddd6ca; color:#2d2e2b;">
<i class="fas fa-cart-plus text-[11px]"></i>${esc(btnLabel)}
</button>`;
document.getElementById('mpe-pc-add-list')?.addEventListener('click', () => {
const amt = usesPacks ? packSize : (def.pantryUnit === 'szt' ? 1 : 10);
const line = {
ingredientId,
amount: amt,
unit: u,
name: product?.name || def.name,
category: def.category,
sourceNote: packLabel || 'Z planera',
};
if (productId) line.productId = productId;
addOrMergeShoppingLines([line]);
// Quick visual feedback
const btn = document.getElementById('mpe-pc-add-list');
if (btn) { btn.textContent = '✓ Dodano'; setTimeout(() => { btn.innerHTML = `<i class="fas fa-cart-plus text-[11px]"></i>${esc(btnLabel)}`; }, 1200); }
window.refreshShopping?.();
});
}
function closeProductCard() {
const overlay = document.getElementById('mpe-product-card-overlay');
if (overlay) {
overlay.classList.add('hidden');
overlay.style.pointerEvents = 'none';
}
}
/* ── HTML template ──────────────────────────────────── */
export function getMealPlanEditorHTML() {
@@ -286,7 +85,7 @@ export function getMealPlanEditorHTML() {
</div>
</div>
</div>
${getProductCardHTML()}`;
${getIngredientCardHTML({ idBase: 'mpe-pc' })}`;
}
/* ── Setup ──────────────────────────────────────────── */
@@ -295,6 +94,8 @@ export function setupMealPlanEditor() {
const overlay = document.getElementById('mpe-overlay');
const sheet = document.getElementById('mpe-sheet');
if (!overlay || !sheet) return;
const ingredientCard = createIngredientCardController({ idBase: 'mpe-pc', defaultSourceNote: 'Z planera' });
ingredientCard.bind();
const S = {
mode: null,
@@ -715,6 +516,7 @@ export function setupMealPlanEditor() {
}
function closeEditor() {
ingredientCard.close();
sheet.style.transform = 'translateY(100%)';
setTimeout(() => { overlay.classList.add('hidden'); overlay.style.pointerEvents = 'none'; }, 300);
}
@@ -856,13 +658,6 @@ export function setupMealPlanEditor() {
renderNutrition();
});
/* ── Product card ────────────────────────────── */
document.getElementById('mpe-pc-close')?.addEventListener('click', closeProductCard);
document.getElementById('mpe-product-card-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'mpe-product-card-overlay') closeProductCard();
});
/* ── Ingredient section delegation ────────────── */
const ingSec = document.getElementById('mpe-ing-section');
@@ -873,7 +668,23 @@ export function setupMealPlanEditor() {
if (!changeProdEarly) {
const cardBtn = e.target.closest('.mpe-open-product-card');
if (cardBtn) {
openProductCard(cardBtn.dataset.eid, cardBtn.dataset.pid || null);
const eid = cardBtn.dataset.eid;
if (!eid) return;
ingredientCard.open({
ingredientId: eid,
productId: cardBtn.dataset.pid || null,
selectedProductId: cardBtn.dataset.pid || null,
sourceNote: 'Z planera',
onProductChange: (nextProductId) => {
if (!nextProductId) return;
S.productSelections[eid] = nextProductId;
saveLastProductSelection(eid, nextProductId);
},
onAfterChange: () => {
renderIngList();
renderNutrition();
},
});
return;
}
}