import { INGREDIENTS, PRODUCTS, CATEGORY_LABELS, getProductsForIngredient, ingredientHasProducts, pantryQtyStep, } from '../data/catalog.js?v=9'; import { addOrMergeShoppingLines, KITCHEN_LIST_ID, loadPantry, loadShoppingState, removeItemFromList, setPantryQty, setPantryProductQty, getPantryTotal, getPantryProducts, updateKitchenItemAmount, } from '../services/pantryShopping.js?v=2'; import { showAppToast } from './toast.js'; const CATEGORY_ICONS = { pieczywo: 'fa-bread-slice', nabial: 'fa-cheese', mieso_ryby: 'fa-drumstick-bite', warzywa: 'fa-carrot', owoce: 'fa-apple-whole', suche: 'fa-wheat-awn', przyprawy: 'fa-leaf', inne: 'fa-jar', }; function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function unitLabel(u) { return u === 'szt' ? 'szt.' : u; } function formatQty(n) { const rounded = Math.round((Number(n) || 0) * 10) / 10; return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, ''); } function formatQtyWithUnit(qty, unit) { return `${formatQty(qty)} ${unitLabel(unit)}`; } function productCountLabel(count) { if (count === 1) return '1 produkt'; const mod10 = count % 10; const mod100 = count % 100; if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) return `${count} produkty`; return `${count} produktów`; } function nutritionForQty(def, qty, nutrition = def?.nutritionPer100g) { if (!def || !nutrition || !Number.isFinite(qty) || qty <= 0) return null; let grams = qty; if (def.pantryUnit === 'szt' && def.weightPerPiece) grams = qty * def.weightPerPiece; const factor = grams / 100; return { kcal: Math.round(nutrition.kcal * factor), protein: Math.round(nutrition.protein * factor * 10) / 10, fat: Math.round(nutrition.fat * factor * 10) / 10, carbs: Math.round(nutrition.carbs * factor * 10) / 10, }; } function macroLine(n) { if (!n) return ''; return `${n.kcal} kcal · ${formatQty(n.protein)}g B · ${formatQty(n.fat)}g T · ${formatQty(n.carbs)}g W`; } function mediaHtml(image, icon, sizeClass = 'w-9 h-9', radiusClass = 'rounded-lg') { if (image) { const fit = image.endsWith('.svg') ? 'object-contain' : 'object-cover'; return ``; } return `
`; } function compactMetaText(text, tone = 'default') { const color = tone === 'success' ? '#6ee7b7' : tone === 'muted' ? '#9b978f' : '#d7d2c8'; return `${esc(text)}`; } function sortProductsByStock(products, pantryItems) { return [...products].sort((a, b) => { const aq = pantryItems.find((i) => i.productId === a.id)?.qty || 0; const bq = pantryItems.find((i) => i.productId === b.id)?.qty || 0; if (bq !== aq) return bq - aq; return a.name.localeCompare(b.name, 'pl'); }); } function formatPackAwareAmount(amount, pantryUnit, packSize, packLabel) { const qty = Number(amount) || 0; const unit = unitLabel(pantryUnit); if (packSize && packSize > 0) { const packs = qty / packSize; if (qty > 0 && Number.isFinite(packs) && Math.abs(packs - Math.round(packs)) < 0.001) { return `${formatQty(Math.round(packs))} x ${packLabel || `${formatQty(packSize)} ${unit}`}`; } } return `${formatQty(qty)} ${unit}`; } function normalizeQty(value) { return Math.max(0, Math.round((Number(value) || 0) * 100) / 100); } function formatPreciseQty(n) { const rounded = Math.round((Number(n) || 0) * 1000) / 1000; if (Number.isInteger(rounded)) return String(rounded); return rounded.toFixed(3).replace(/0+$/, '').replace(/\.$/, ''); } function formatPackCount(amount, packSize) { if (!Number.isFinite(Number(packSize)) || Number(packSize) <= 0) return ''; return `${formatPreciseQty((Number(amount) || 0) / Number(packSize))} opak.`; } function getQtyStepMeta(def, product = null) { const productPackSize = Number(product?.packSize); if (Number.isFinite(productPackSize) && productPackSize > 0) { return { step: productPackSize, usesPackStep: true, stepLabel: product?.packLabel || formatQtyWithUnit(productPackSize, def.pantryUnit), }; } const ingredientPackSize = Number(def?.purchasePack?.amount); if (Number.isFinite(ingredientPackSize) && ingredientPackSize > 0) { return { step: ingredientPackSize, usesPackStep: true, stepLabel: def.purchasePack?.label || formatQtyWithUnit(ingredientPackSize, def.pantryUnit), }; } return { step: pantryQtyStep(def.id), usesPackStep: false, stepLabel: '', }; } export function getIngredientCardHTML({ idBase, overlayClass = 'fixed inset-0 z-[70] hidden opacity-0 transition-opacity duration-200 flex items-center justify-center p-5', overlayStyle = 'pointer-events:none; background:rgba(0,0,0,0.5);', cardClass = 'relative w-full max-w-xs rounded-2xl shadow-2xl overflow-hidden', cardStyle = 'background:#2d2e2b; pointer-events:auto; max-height:85vh; overflow-y:auto; transform:translateY(0.75rem); opacity:0; transition:transform 220ms ease, opacity 220ms ease;', } = {}) { if (!idBase) throw new Error('getIngredientCardHTML requires idBase'); return `

`; } export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze spiżarni' } = {}) { if (!idBase) throw new Error('createIngredientCardController requires idBase'); const state = { ingredientId: null, productId: null, selectedProductId: null, allowProductSelection: true, sourceNote: defaultSourceNote, onProductChange: null, onAfterChange: null, stockEditorOpen: false, stockDraftQty: null, shopEditorOpen: false, shopDraftQty: null, closeTimer: null, }; let bound = false; const el = (suffix = '') => document.getElementById(suffix ? `${idBase}-${suffix}` : idBase); function resetInlineEditors() { state.stockEditorOpen = false; state.stockDraftQty = null; state.shopEditorOpen = false; state.shopDraftQty = null; } function getShoppingItemFor(def, product) { const shopping = loadShoppingState(); const kitchen = shopping.lists.find((list) => list.id === KITCHEN_LIST_ID && list.type === 'kitchen'); if (!kitchen || kitchen.type !== 'kitchen') return null; const unit = unitLabel(def.pantryUnit); const targetProductId = product?.id || ''; return kitchen.items.find((item) => { if (item.checked) return false; return item.ingredientId === def.id && item.unit === unit && (item.productId || '') === targetProductId; }) || null; } function renderHeader(def, product, pantry) { const hasProducts = ingredientHasProducts(def.id); const isListMode = hasProducts && state.allowProductSelection && !state.productId; const isBackAvailable = hasProducts && state.allowProductSelection && state.productId; const icon = CATEGORY_ICONS[def.category] || 'fa-jar'; const heroEl = el('hero'); const img = el('img'); const fallback = el('fallback'); const fallbackIcon = el('fallback-icon'); if (img && fallback) { const image = isListMode ? def.image : (product?.image || def.image); const altName = isListMode ? def.name : (product?.name || def.name); if (image) { img.src = image; img.alt = altName; const isSvg = image.endsWith('.svg'); img.classList.toggle('object-contain', isSvg); img.classList.toggle('object-cover', !isSvg); img.style.padding = isSvg ? '6px' : ''; img.classList.remove('hidden'); fallback.classList.add('hidden'); } else { img.classList.add('hidden'); fallback.classList.remove('hidden'); if (fallbackIcon) fallbackIcon.className = `fas ${icon} text-2xl`; } } const categoryEl = el('category'); const nameEl = el('name'); const subtitleEl = el('subtitle'); const backBtn = el('back'); const displayProduct = isListMode ? null : product; if (categoryEl) categoryEl.textContent = displayProduct?.brand || CATEGORY_LABELS[def.category] || def.category; if (nameEl) nameEl.textContent = displayProduct?.name || def.name; if (subtitleEl) { let subtitle = ''; if (displayProduct) { subtitle = [def.name, displayProduct.packLabel].filter(Boolean).join(' • '); } else if (!hasProducts && def.purchasePack?.label) { subtitle = def.purchasePack.label; } if (subtitle) { subtitleEl.textContent = subtitle; subtitleEl.classList.remove('hidden'); } else { subtitleEl.classList.add('hidden'); } } if (backBtn) { backBtn.classList.toggle('hidden', !isBackAvailable); backBtn.classList.toggle('flex', Boolean(isBackAvailable)); } } function renderNutrition(def, product) { const wrap = el('nutrition'); if (!wrap) return; const hasProducts = ingredientHasProducts(def.id); const isListMode = hasProducts && state.allowProductSelection && !state.productId; if (isListMode) { wrap.innerHTML = ''; return; } const nutrition = product?.nutritionPer100g || def.nutritionPer100g; if (!nutrition) { wrap.innerHTML = ''; return; } const unitScope = def.pantryUnit === 'ml' ? 'na 100 ml' : 'na 100 g'; const hint = product ? '' : hasProducts ? 'orientacyjnie dla składnika' : ''; const nutritionMeta = hint ? `${unitScope} • ${hint}` : unitScope; wrap.innerHTML = `

Wartości odżywcze

${esc(nutritionMeta)}

${nutrition.kcal}

kcal

${formatQty(nutrition.protein)}g

białko

${formatQty(nutrition.fat)}g

tłuszcz

${formatQty(nutrition.carbs)}g

węgl.

`; } function renderStockEditorInto(wrap, def, product, pantry) { const totalQty = getPantryTotal(def.id, pantry); const unit = unitLabel(def.pantryUnit); const qty = product ? (getPantryProducts(def.id, pantry).find((i) => i.productId === product.id)?.qty || 0) : totalQty; const { step, usesPackStep } = getQtyStepMeta(def, product); const packSize = product?.packSize || def.purchasePack?.amount || 0; const packLabel = product?.packLabel || def.purchasePack?.label || ''; const draftQty = state.stockEditorOpen ? normalizeQty(state.stockDraftQty ?? qty) : qty; const stockValueLabel = usesPackStep ? formatPackCount(qty, step) : formatPackAwareAmount(qty, def.pantryUnit, packSize, packLabel); const stockSubLabel = usesPackStep ? formatQtyWithUnit(qty, def.pantryUnit) : ''; const draftInputValue = usesPackStep ? formatPreciseQty(draftQty / step) : formatPreciseQty(draftQty); const draftInputUnit = usesPackStep ? 'opak.' : unit; const actionLabel = state.stockEditorOpen ? 'Anuluj' : 'Zmień'; wrap.innerHTML = `

Zapas

${esc(stockValueLabel)}

${stockSubLabel ? `

${esc(stockSubLabel)}

` : ''}
${state.stockEditorOpen ? `
${usesPackStep ? `

${esc(formatQtyWithUnit(draftQty, def.pantryUnit))}

` : ''}
` : ''}
`; wrap.querySelector('.ingredient-card-stock-toggle')?.addEventListener('click', () => { if (state.stockEditorOpen) { state.stockEditorOpen = false; state.stockDraftQty = null; } else { state.stockEditorOpen = true; state.shopEditorOpen = false; state.stockDraftQty = qty; } render(); }); wrap.querySelectorAll('.ingredient-card-stock-step').forEach((btn) => { btn.addEventListener('click', () => { const dir = Number(btn.dataset.dir) || 1; const next = normalizeQty((Number(state.stockDraftQty ?? qty) || 0) + step * dir); state.stockDraftQty = next; render(); }); }); wrap.querySelector('.ingredient-card-stock-input')?.addEventListener('input', (event) => { state.stockDraftQty = usesPackStep ? normalizeQty((Number(event.target.value) || 0) * step) : normalizeQty(event.target.value); }); wrap.querySelector('.ingredient-card-stock-clear')?.addEventListener('click', () => { state.stockDraftQty = 0; render(); }); wrap.querySelector('.ingredient-card-stock-save')?.addEventListener('click', () => { const input = wrap.querySelector('.ingredient-card-stock-input'); const nextQty = usesPackStep ? normalizeQty((Number(input?.value) || 0) * step) : normalizeQty(input?.value ?? state.stockDraftQty ?? qty); if (product) { setPantryProductQty(def.id, product.id, nextQty); } else { setPantryQty(def.id, nextQty); } state.stockEditorOpen = false; state.stockDraftQty = null; render(); state.onAfterChange?.(); }); } function renderShopEditorInto(wrap, def, product) { const { step, usesPackStep } = getQtyStepMeta(def, product); const packSize = product?.packSize || def.purchasePack?.amount; const packLabel = product?.packLabel || def.purchasePack?.label; const usesPacks = Boolean(packSize && packSize > 0); const defaultAmount = step; const shoppingItem = getShoppingItemFor(def, product); const hasShoppingItem = Boolean(shoppingItem); const shoppingAmount = shoppingItem?.amount || 0; const draftQty = state.shopEditorOpen ? normalizeQty(state.shopDraftQty ?? (shoppingAmount || defaultAmount)) : shoppingAmount; const shopValueLabel = hasShoppingItem ? usesPackStep ? formatPackCount(shoppingAmount, step) : formatPackAwareAmount(shoppingAmount, def.pantryUnit, packSize, packLabel) : 'Brak na liście'; const shopSubLabel = hasShoppingItem && usesPackStep ? formatQtyWithUnit(shoppingAmount, def.pantryUnit) : ''; const shopInputValue = usesPackStep ? formatPreciseQty(draftQty / step) : formatPreciseQty(draftQty); const shopInputUnit = usesPackStep ? 'opak.' : unitLabel(def.pantryUnit); const actionLabel = state.shopEditorOpen ? 'Anuluj' : (hasShoppingItem ? 'Zmień' : 'Dodaj'); wrap.innerHTML = `

Lista zakupów

${esc(shopValueLabel)}

${shopSubLabel ? `

${esc(shopSubLabel)}

` : ''}
${state.shopEditorOpen ? `
${usesPackStep ? `

${esc(formatQtyWithUnit(draftQty, def.pantryUnit))}

` : ''}
${hasShoppingItem ? '' : ''}
` : ''}
`; wrap.querySelector('.ingredient-card-shop-toggle')?.addEventListener('click', () => { if (state.shopEditorOpen) { state.shopEditorOpen = false; state.shopDraftQty = null; } else { state.shopEditorOpen = true; state.stockEditorOpen = false; state.shopDraftQty = shoppingAmount || defaultAmount; } render(); }); wrap.querySelectorAll('.ingredient-card-shop-step').forEach((btn) => { btn.addEventListener('click', () => { const dir = Number(btn.dataset.dir) || 1; const next = normalizeQty((Number(state.shopDraftQty ?? (shoppingAmount || defaultAmount)) || 0) + step * dir); state.shopDraftQty = next; render(); }); }); wrap.querySelector('.ingredient-card-shop-input')?.addEventListener('input', (event) => { state.shopDraftQty = usesPackStep ? normalizeQty((Number(event.target.value) || 0) * step) : normalizeQty(event.target.value); }); wrap.querySelector('.ingredient-card-shop-remove')?.addEventListener('click', () => { if (!shoppingItem) return; removeItemFromList(KITCHEN_LIST_ID, shoppingItem.id); state.shopEditorOpen = false; state.shopDraftQty = null; render(); state.onAfterChange?.(); window.refreshShopping?.(); showAppToast(`Usunieto ${product?.name || def.name} z listy.`); }); wrap.querySelector('.ingredient-card-shop-save')?.addEventListener('click', () => { const input = wrap.querySelector('.ingredient-card-shop-input'); const nextAmount = usesPackStep ? normalizeQty((Number(input?.value) || 0) * step) : normalizeQty(input?.value ?? state.shopDraftQty ?? defaultAmount); let toastText = null; if (shoppingItem) { updateKitchenItemAmount(KITCHEN_LIST_ID, shoppingItem.id, nextAmount); toastText = nextAmount > 0 ? `Zaktualizowano ${product?.name || def.name}.` : `Usunieto ${product?.name || def.name} z listy.`; } else if (nextAmount > 0) { const note = usesPacks ? (packLabel || `${formatQty(packSize)} ${unitLabel(def.pantryUnit)}`) : undefined; const line = { ingredientId: def.id, amount: nextAmount, unit: unitLabel(def.pantryUnit), name: product?.name || def.name, category: def.category, sourceNote: note || state.sourceNote || defaultSourceNote, }; if (product) line.productId = product.id; addOrMergeShoppingLines([line]); toastText = `Dodano ${product?.name || def.name}.`; } state.shopEditorOpen = false; state.shopDraftQty = null; render(); state.onAfterChange?.(); window.refreshShopping?.(); if (toastText) showAppToast(toastText); }); } function renderStock() { const wrap = el('stock'); if (!wrap || !state.ingredientId) return; const def = INGREDIENTS[state.ingredientId]; if (!def) return; const hasProducts = ingredientHasProducts(state.ingredientId); const isListMode = hasProducts && state.allowProductSelection && !state.productId; if (isListMode) { wrap.innerHTML = ''; return; } const pantry = loadPantry(); const product = state.productId ? PRODUCTS[state.productId] : null; renderStockEditorInto(wrap, def, product, pantry); } function productRowHtml(ingredientId, productId, pantry, kitchenItems) { const def = INGREDIENTS[ingredientId]; const product = PRODUCTS[productId]; const icon = CATEGORY_ICONS[def.category] || 'fa-jar'; const unit = unitLabel(def.pantryUnit); const pantryQty = getPantryProducts(ingredientId, pantry).find((i) => i.productId === productId)?.qty || 0; const shoppingItem = kitchenItems.find((item) => !item.checked && item.ingredientId === ingredientId && item.unit === unit && (item.productId || '') === productId); const shoppingAmount = shoppingItem?.amount || 0; const pantryLabel = pantryQty > 0 ? `${formatQty(pantryQty)} ${unit}` : '—'; const pantryColor = pantryQty > 0 ? '#ddd6ca' : '#6d6c67'; const shoppingLabel = shoppingAmount > 0 ? `${formatQty(shoppingAmount)} ${unit}` : ''; return ``; } function renderProducts() { const wrap = el('products'); if (!wrap || !state.ingredientId) return; const hasProducts = ingredientHasProducts(state.ingredientId); const isListMode = hasProducts && state.allowProductSelection && !state.productId; if (!isListMode) { wrap.innerHTML = ''; return; } const pantry = loadPantry(); const products = sortProductsByStock(getProductsForIngredient(state.ingredientId), getPantryProducts(state.ingredientId, pantry)); const shopping = loadShoppingState(); const kitchen = shopping.lists.find((list) => list.id === KITCHEN_LIST_ID && list.type === 'kitchen'); const kitchenItems = (kitchen && kitchen.type === 'kitchen') ? kitchen.items : []; wrap.innerHTML = `

Produkty

${products.map((product) => productRowHtml(state.ingredientId, product.id, pantry, kitchenItems)).join('')}
`; wrap.querySelectorAll('.ingredient-card-product-row').forEach((btn) => { btn.addEventListener('click', () => { const nextProductId = btn.dataset.productId || null; if (!nextProductId) return; state.productId = nextProductId; state.selectedProductId = nextProductId; resetInlineEditors(); state.onProductChange?.(nextProductId); render(); state.onAfterChange?.(); }); }); } function renderShop() { const wrap = el('shop'); if (!wrap || !state.ingredientId) return; const def = INGREDIENTS[state.ingredientId]; if (!def) return; const hasProducts = ingredientHasProducts(state.ingredientId); const isListMode = hasProducts && state.allowProductSelection && !state.productId; if (isListMode) { wrap.innerHTML = ''; return; } const product = state.productId ? PRODUCTS[state.productId] : null; renderShopEditorInto(wrap, def, product); } function render() { if (!state.ingredientId) return; const def = INGREDIENTS[state.ingredientId]; if (!def) return; const product = state.productId ? PRODUCTS[state.productId] : null; const pantry = loadPantry(); renderHeader(def, product, pantry); renderNutrition(def, product); renderStock(); renderProducts(); renderShop(); } function open({ ingredientId, productId = null, selectedProductId = productId, allowProductSelection = true, sourceNote = defaultSourceNote, onProductChange = null, onAfterChange = null, } = {}) { const def = ingredientId ? INGREDIENTS[ingredientId] : null; const overlay = el('overlay'); const card = el(); if (!def || !overlay || !card) return; state.ingredientId = ingredientId; state.productId = productId && PRODUCTS[productId] ? productId : null; state.selectedProductId = selectedProductId && PRODUCTS[selectedProductId] ? selectedProductId : state.productId; state.allowProductSelection = Boolean(allowProductSelection); state.sourceNote = sourceNote; state.onProductChange = onProductChange; state.onAfterChange = onAfterChange; resetInlineEditors(); render(); clearTimeout(state.closeTimer); overlay.classList.remove('hidden'); overlay.style.pointerEvents = 'auto'; requestAnimationFrame(() => { overlay.classList.add('opacity-100'); card.style.opacity = '1'; card.style.transform = 'translateY(0)'; }); } function close() { const overlay = el('overlay'); const card = el(); if (overlay && card) { overlay.classList.remove('opacity-100'); overlay.style.pointerEvents = 'none'; card.style.opacity = '0'; card.style.transform = 'translateY(1.5rem)'; state.closeTimer = setTimeout(() => overlay.classList.add('hidden'), 220); } state.ingredientId = null; state.productId = null; state.selectedProductId = null; state.allowProductSelection = true; state.onProductChange = null; state.onAfterChange = null; state.sourceNote = defaultSourceNote; resetInlineEditors(); } function bind() { if (bound) return; bound = true; el('close')?.addEventListener('click', close); el('back')?.addEventListener('click', () => { if (!state.allowProductSelection) return; state.productId = null; resetInlineEditors(); render(); }); el('overlay')?.addEventListener('click', (event) => { if (event.target.id === `${idBase}-overlay`) close(); }); } function refresh() { if (state.ingredientId) render(); } return { bind, open, close, refresh, isOpen: () => Boolean(state.ingredientId), }; }