${escapeHtml(ing.name)}
${formatAmount(ing.amount)} ${escapeHtml(ing.unit)}
@@ -1042,7 +739,7 @@ export function setupMealPlanner() {
const pick = e.target.closest('.planner-pick-recipe');
if (!pick || !state.pickerSlot) return;
const recipeId = pick.getAttribute('data-recipe-id');
- if (!recipeId || !PLANNER_RECIPES[recipeId]) return;
+ if (!recipeId || !RECIPES[recipeId]) return;
const key = dateKey(state.selected);
if (!state.plans[key]) state.plans[key] = {};
const slotId = state.pickerSlot;
@@ -1073,7 +770,24 @@ export function setupMealPlanner() {
const rows = body?.querySelectorAll('.planner-ing-row');
const n = rows?.length ?? 0;
if (n === 0) return;
- showPlannerToast(`Dodano ${n} składników do listy (zakładka Zakupy w przygotowaniu).`);
+ const lines = [];
+ rows.forEach((row) => {
+ const id = row.getAttribute('data-ingredient-id');
+ const amount = parseFloat(row.getAttribute('data-amount') || '');
+ const unit = row.getAttribute('data-unit') || '';
+ const category = row.getAttribute('data-category') || '';
+ if (!id || !Number.isFinite(amount)) return;
+ lines.push({
+ ingredientId: id,
+ amount,
+ unit,
+ category,
+ sourceNote: 'Z planu dnia',
+ });
+ });
+ addOrMergeShoppingLines(lines);
+ showPlannerToast(`Dodano ${lines.length} składników na listę.`);
+ window.refreshShopping?.();
closeSheet(ingBackdrop, ingSheet);
});
@@ -1085,7 +799,24 @@ export function setupMealPlanner() {
showPlannerToast('Zaznacz składniki na liście albo użyj „Dodaj wszystkie”.');
return;
}
- showPlannerToast(`Dodano ${n} zaznaczonych pozycji do listy (zakładka Zakupy w przygotowaniu).`);
+ const lines = [];
+ selected.forEach((row) => {
+ const id = row.getAttribute('data-ingredient-id');
+ const amount = parseFloat(row.getAttribute('data-amount') || '');
+ const unit = row.getAttribute('data-unit') || '';
+ const category = row.getAttribute('data-category') || '';
+ if (!id || !Number.isFinite(amount)) return;
+ lines.push({
+ ingredientId: id,
+ amount,
+ unit,
+ category,
+ sourceNote: 'Z planu dnia',
+ });
+ });
+ addOrMergeShoppingLines(lines);
+ showPlannerToast(`Dodano ${lines.length} pozycji na listę.`);
+ window.refreshShopping?.();
closeSheet(ingBackdrop, ingSheet);
});
diff --git a/stacks/recipe/js/views/Pantry.js b/stacks/recipe/js/views/Pantry.js
new file mode 100644
index 0000000..b9ed79f
--- /dev/null
+++ b/stacks/recipe/js/views/Pantry.js
@@ -0,0 +1,412 @@
+import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js';
+import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty } from '../services/pantryShopping.js';
+import { showAppToast } from '../ui/toast.js';
+
+function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function pantryUnitLabel(u) {
+ if (u === 'szt') return 'szt.';
+ return u;
+}
+
+function normalizeSearch(q) {
+ return String(q).trim().toLowerCase();
+}
+
+const PANTRY_SHOP_BOTTOM = '5.25rem';
+const PANTRY_SHOP_OFF = `translateY(calc(100% + ${PANTRY_SHOP_BOTTOM}))`;
+
+/** @type {string | null} */
+let shopPickerIngredientId = null;
+/** @type {number} */
+let shopPickerStep = 1;
+
+export function getPantryHTML() {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+let pantryFilterCategory = '';
+
+/** Zwijanie sekcji (ignorowane przy aktywnym wyszukiwaniu — widać wszystkie trafienia). */
+let pantryAccordionHaveOpen = true;
+let pantryAccordionCatalogOpen = false;
+
+function allCategoryKeys() {
+ const s = new Set();
+ Object.values(INGREDIENTS).forEach((d) => s.add(d.category));
+ return [...s].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)));
+}
+
+function renderCategoryChips() {
+ const wrap = document.getElementById('pantry-category-filters');
+ if (!wrap) return;
+
+ const keys = allCategoryKeys();
+ const chips = [
+ { id: '', label: 'Wszystkie' },
+ ...keys.map((k) => ({ id: k, label: categoryLabel(k) })),
+ ];
+
+ wrap.innerHTML = chips.map((c) => {
+ const active = c.id === pantryFilterCategory;
+ const cls = active
+ ? 'shrink-0 px-3 py-1.5 rounded-full text-[11px] font-semibold bg-gray-900 text-white'
+ : 'shrink-0 px-3 py-1.5 rounded-full text-[11px] font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200';
+ return ``;
+ }).join('');
+
+ wrap.querySelectorAll('.pantry-cat-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ pantryFilterCategory = btn.getAttribute('data-pantry-cat') || '';
+ renderCategoryChips();
+ renderPantryResults();
+ });
+ });
+}
+
+function filterIds(searchRaw) {
+ const q = normalizeSearch(searchRaw);
+ return Object.keys(INGREDIENTS)
+ .filter((id) => {
+ const d = INGREDIENTS[id];
+ if (pantryFilterCategory && d.category !== pantryFilterCategory) return false;
+ if (!q) return true;
+ const name = d.name.toLowerCase();
+ const cat = (CATEGORY_LABELS[d.category] || '').toLowerCase();
+ return name.includes(q) || cat.includes(q);
+ })
+ .sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl'));
+}
+
+/** Krok +/-: tylko liczby całkowite (szt. ±1, g/ml ±10). */
+function qtyStepForIngredient(id) {
+ const u = INGREDIENTS[id]?.pantryUnit;
+ return u === 'szt' ? 1 : 10;
+}
+
+function splitHaveAndCatalog(ids, pantry) {
+ const have = ids.filter((id) => (Number(pantry[id]) || 0) > 0);
+ const catalogOnly = ids.filter((id) => !pantry[id] || Number(pantry[id]) <= 0);
+ return { have, catalogOnly };
+}
+
+/**
+ * @param {'have' | 'catalog'} sectionKey
+ * @param {{ title: string, hint: string, count: number, tone: 'emerald' | 'slate', open: boolean, searching: boolean, bodyInner: string }} opts
+ */
+function pantryAccordionSection(sectionKey, opts) {
+ const { title, hint, count, tone, open, searching, bodyInner } = opts;
+ const showToggle = !searching && count > 0;
+ const isOpen = searching || open || count === 0;
+ const ring = tone === 'emerald' ? 'ring-emerald-100/90' : 'ring-gray-200/90';
+ const dot = tone === 'emerald' ? 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.2)]' : 'bg-slate-400 shadow-[0_0_0_3px_rgba(148,163,184,0.25)]';
+ const chevronRot = isOpen ? '' : '-rotate-90';
+
+ const rowCls =
+ 'w-full flex items-center gap-3 px-3.5 py-3 text-left min-h-[3.25rem]' +
+ (showToggle ? ' hover:bg-gray-50/80 transition-colors pantry-acc-toggle cursor-pointer' : '');
+
+ const chevron =
+ showToggle
+ ? ``
+ : '';
+
+ const headerInner = `
+
+
+
+ ${escapeHtml(title)}
+ ${count}
+
+ ${escapeHtml(hint)}
+
+ ${chevron}`;
+
+ const header =
+ showToggle
+ ? ``
+ : `${headerInner}
`;
+
+ return `
+
+ ${header}
+
+ ${bodyInner}
+
+ `;
+}
+
+function pantryCardHtml(id, pantry, variant) {
+ const def = INGREDIENTS[id];
+ const unit = pantryUnitLabel(def.pantryUnit);
+ const qty = Number(pantry[id]) || 0;
+ const val = qty > 0 ? String(Math.round(qty)) : '';
+ const step = qtyStepForIngredient(id);
+
+ const shell = variant === 'have'
+ ? 'rounded-xl border border-emerald-200 bg-gradient-to-br from-emerald-50/80 to-white p-3 shadow-sm ring-1 ring-emerald-100/80'
+ : 'rounded-xl border border-dashed border-gray-200 bg-gray-50/90 p-3 shadow-sm';
+
+ return `
+
+
+
+
${escapeHtml(def.name)}
+
${escapeHtml(categoryLabel(def.category))} · magazyn: ${unit}
+
+
+
+
+
+
+
+
+
+
+
`;
+}
+
+function renderPantryResults() {
+ const root = document.getElementById('pantry-results');
+ if (!root) return;
+
+ const searchEl = document.getElementById('pantry-search');
+ const q = searchEl?.value || '';
+ const searching = normalizeSearch(q) !== '';
+ const pantry = loadPantry();
+ const ids = filterIds(q);
+
+ if (ids.length === 0) {
+ root.innerHTML = 'Brak wyników — zmień wyszukiwanie lub filtr kategorii.
';
+ return;
+ }
+
+ const { have, catalogOnly } = splitHaveAndCatalog(ids, pantry);
+
+ const haveBody = have.length
+ ? `${have.map((id) => pantryCardHtml(id, pantry, 'have')).join('')}
`
+ : 'Żaden z widocznych produktów nie ma jeszcze zapasu — ustaw ilość w katalogu poniżej.
';
+
+ const catBody = catalogOnly.length
+ ? `${catalogOnly.map((id) => pantryCardHtml(id, pantry, 'catalog')).join('')}
`
+ : 'Wszystkie widoczne pozycje są na stanie.
';
+
+ const haveHint =
+ have.length === 0
+ ? 'Brak zapasu w tym widoku'
+ : have.length === 1
+ ? '1 produkt z zapasem'
+ : `${have.length} produktów z zapasem`;
+
+ const catHint =
+ catalogOnly.length === 0
+ ? 'Nic do uzupełnienia w tym widoku'
+ : catalogOnly.length === 1
+ ? '1 pozycja bez zapasu'
+ : `${catalogOnly.length} pozycji bez zapasu`;
+
+ root.innerHTML =
+ pantryAccordionSection('have', {
+ title: 'Na stanie',
+ hint: haveHint,
+ count: have.length,
+ tone: 'emerald',
+ open: pantryAccordionHaveOpen,
+ searching,
+ bodyInner: haveBody,
+ }) +
+ pantryAccordionSection('catalog', {
+ title: 'Katalog — bez zapasu',
+ hint: catHint,
+ count: catalogOnly.length,
+ tone: 'slate',
+ open: pantryAccordionCatalogOpen,
+ searching,
+ bodyInner: catBody,
+ });
+
+ root.querySelectorAll('.pantry-acc-toggle').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const key = btn.getAttribute('data-pantry-acc');
+ if (key === 'have') pantryAccordionHaveOpen = !pantryAccordionHaveOpen;
+ else if (key === 'catalog') pantryAccordionCatalogOpen = !pantryAccordionCatalogOpen;
+ renderPantryResults();
+ });
+ });
+
+ root.querySelectorAll('[data-ingredient-id]').forEach((card) => {
+ const id = card.getAttribute('data-ingredient-id');
+ if (!id) return;
+ const input = card.querySelector('[data-pantry-qty]');
+ const step = parseInt(String(input?.getAttribute('data-pantry-step')), 10) || qtyStepForIngredient(id);
+
+ const applyQty = (n) => {
+ const v = Math.max(0, Math.round(Number(n)) || 0);
+ setPantryQty(id, v);
+ if (input) {
+ input.value = v > 0 ? String(v) : '';
+ }
+ const prevVariant = card.getAttribute('data-pantry-variant');
+ const nowHave = v > 0;
+ const expectVariant = nowHave ? 'have' : 'catalog';
+ if (prevVariant !== expectVariant) {
+ renderPantryResults();
+ }
+ };
+
+ card.querySelector('.pantry-qty-minus')?.addEventListener('click', () => {
+ const cur = Math.round(parseFloat(String(input?.value).replace(',', '.')) || 0);
+ applyQty(Math.max(0, cur - step));
+ });
+ card.querySelector('.pantry-qty-plus')?.addEventListener('click', () => {
+ const cur = Math.round(parseFloat(String(input?.value).replace(',', '.')) || 0);
+ applyQty(cur + step);
+ });
+ input?.addEventListener('change', () => {
+ const raw = String(input.value).replace(',', '.').trim();
+ const v = raw === '' ? 0 : Math.round(parseFloat(raw));
+ applyQty(Number.isFinite(v) ? v : 0);
+ });
+ card.querySelector('.pantry-add-shop')?.addEventListener('click', () => {
+ openPantryShopPicker(id);
+ });
+ });
+}
+
+function readShopPickerQty() {
+ const el = document.getElementById('pantry-shop-qty');
+ const n = Math.round(parseFloat(String(el?.value).replace(',', '.')) || 0);
+ return Math.max(1, n);
+}
+
+function setShopPickerQtyDisplay(v) {
+ const el = document.getElementById('pantry-shop-qty');
+ if (el) el.value = String(Math.max(1, Math.round(v)));
+}
+
+function openPantryShopPicker(ingredientId) {
+ const def = INGREDIENTS[ingredientId];
+ if (!def) return;
+
+ shopPickerIngredientId = ingredientId;
+ shopPickerStep = qtyStepForIngredient(ingredientId);
+ const unit = pantryUnitLabel(def.pantryUnit);
+
+ const heading = document.getElementById('pantry-shop-heading');
+ const sub = document.getElementById('pantry-shop-sub');
+ if (heading) heading.textContent = `Ile dodać: ${def.name}?`;
+ if (sub) {
+ sub.textContent = `Jednostka na liście: ${unit}. Przyciski +/−: ${shopPickerStep} ${unit}.`;
+ }
+ setShopPickerQtyDisplay(shopPickerStep);
+
+ const backdrop = document.getElementById('pantry-shop-backdrop');
+ const sheet = document.getElementById('pantry-shop-sheet');
+ if (!backdrop || !sheet) return;
+
+ sheet.classList.remove('hidden');
+ backdrop.classList.remove('hidden');
+ requestAnimationFrame(() => {
+ backdrop.classList.remove('opacity-0');
+ sheet.style.transform = 'translateY(0)';
+ });
+}
+
+function closePantryShopPicker() {
+ shopPickerIngredientId = null;
+ const backdrop = document.getElementById('pantry-shop-backdrop');
+ const sheet = document.getElementById('pantry-shop-sheet');
+ if (sheet) {
+ sheet.style.transform = PANTRY_SHOP_OFF;
+ }
+ if (backdrop) {
+ backdrop.classList.add('opacity-0');
+ }
+ setTimeout(() => {
+ backdrop?.classList.add('hidden');
+ sheet?.classList.add('hidden');
+ }, 300);
+}
+
+function bindPantryShopSheet() {
+ document.getElementById('pantry-shop-backdrop')?.addEventListener('click', closePantryShopPicker);
+ document.getElementById('pantry-shop-cancel')?.addEventListener('click', closePantryShopPicker);
+
+ document.getElementById('pantry-shop-minus')?.addEventListener('click', () => {
+ const cur = readShopPickerQty();
+ setShopPickerQtyDisplay(Math.max(1, cur - shopPickerStep));
+ });
+ document.getElementById('pantry-shop-plus')?.addEventListener('click', () => {
+ const cur = readShopPickerQty();
+ setShopPickerQtyDisplay(cur + shopPickerStep);
+ });
+ document.getElementById('pantry-shop-qty')?.addEventListener('change', () => {
+ setShopPickerQtyDisplay(readShopPickerQty());
+ });
+
+ document.getElementById('pantry-shop-add')?.addEventListener('click', () => {
+ if (!shopPickerIngredientId) return;
+ const qty = readShopPickerQty();
+ addIngredientToKitchenList(shopPickerIngredientId, qty);
+ showAppToast(`Dodano ${qty} na listę kuchni.`);
+ closePantryShopPicker();
+ window.refreshShopping?.();
+ });
+}
+
+export function refreshPantry() {
+ renderCategoryChips();
+ renderPantryResults();
+}
+
+export function setupPantry() {
+ renderCategoryChips();
+ renderPantryResults();
+ bindPantryShopSheet();
+
+ document.getElementById('pantry-search')?.addEventListener('input', () => {
+ renderPantryResults();
+ });
+
+ window.refreshPantry = refreshPantry;
+}
diff --git a/stacks/recipe/js/views/Shopping.js b/stacks/recipe/js/views/Shopping.js
new file mode 100644
index 0000000..7c82b74
--- /dev/null
+++ b/stacks/recipe/js/views/Shopping.js
@@ -0,0 +1,237 @@
+import {
+ addFreeformLine,
+ addFreeformList,
+ categoryLabel,
+ deleteList,
+ getActiveList,
+ getListSummaries,
+ KITCHEN_LIST_ID,
+ removeItemFromList,
+ setActiveListId,
+ toggleItemInList,
+} from '../services/pantryShopping.js';
+import { showAppToast } from '../ui/toast.js';
+
+function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+export function getShoppingHTML() {
+ return `
+
+ `;
+}
+
+function syncListSelect() {
+ const sel = document.getElementById('shopping-list-select');
+ if (!sel) return;
+ const { lists, activeListId } = getListSummaries();
+ sel.innerHTML = lists.map((l) => {
+ const suffix = l.openCount ? ` (${l.openCount})` : '';
+ const label = `${l.name}${suffix}`;
+ return ``;
+ }).join('');
+ sel.value = activeListId;
+}
+
+function syncChromeForList() {
+ const list = getActiveList();
+ const isKitchen = list.type === 'kitchen';
+ const delBtn = document.getElementById('shopping-delete-list');
+ const ffAdd = document.getElementById('shopping-freeform-add');
+
+ if (ffAdd) ffAdd.classList.toggle('hidden', isKitchen);
+
+ if (delBtn) {
+ delBtn.classList.toggle('hidden', isKitchen);
+ }
+}
+
+function renderKitchenItems() {
+ const root = document.getElementById('shopping-list-root');
+ if (!root) return;
+
+ const list = getActiveList();
+ if (list.type !== 'kitchen') return;
+
+ const items = list.items;
+ if (items.length === 0) {
+ root.innerHTML = 'Brak pozycji.
';
+ return;
+ }
+
+ const groups = {};
+ for (const it of items) {
+ const c = it.category || 'inne';
+ if (!groups[c]) groups[c] = [];
+ groups[c].push(it);
+ }
+
+ root.innerHTML = Object.keys(groups)
+ .sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)))
+ .map((cat) => `
+
+
${escapeHtml(categoryLabel(cat))}
+
+ ${groups[cat].map((it) => `
+ -
+
+
+
${escapeHtml(it.name)}
+
${escapeHtml(String(it.amount))} ${escapeHtml(it.unit)}
+ ${it.sourceNote ? `
${escapeHtml(it.sourceNote)}
` : ''}
+
+
+ `).join('')}
+
+
+ `)
+ .join('');
+
+ bindItemButtons(list.id);
+}
+
+function renderFreeformItems() {
+ const root = document.getElementById('shopping-list-root');
+ if (!root) return;
+
+ const list = getActiveList();
+ if (list.type !== 'freeform') return;
+
+ const items = list.items;
+ if (items.length === 0) {
+ root.innerHTML = 'Dodaj dowolny tekst powyżej — bez powiązania z katalogiem składników.
';
+ return;
+ }
+
+ root.innerHTML = `
+
+
+ ${items.map((it) => `
+ -
+
+
+
${escapeHtml(it.text)}
+ ${it.note ? `
${escapeHtml(it.note)}
` : ''}
+
+
+ `).join('')}
+
+
`;
+
+ bindItemButtons(list.id);
+}
+
+function bindItemButtons(listId) {
+ const root = document.getElementById('shopping-list-root');
+ if (!root) return;
+
+ root.querySelectorAll('[data-shop-toggle]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const id = btn.getAttribute('data-shop-toggle');
+ if (id) toggleItemInList(listId, id);
+ refreshShopping();
+ });
+ });
+ root.querySelectorAll('[data-shop-remove]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const id = btn.getAttribute('data-shop-remove');
+ if (id) removeItemFromList(listId, id);
+ refreshShopping();
+ });
+ });
+}
+
+export function refreshShopping() {
+ syncListSelect();
+ syncChromeForList();
+ const list = getActiveList();
+ if (list.type === 'kitchen') renderKitchenItems();
+ else renderFreeformItems();
+}
+
+export function setupShopping() {
+ const sel = document.getElementById('shopping-list-select');
+ sel?.addEventListener('change', () => {
+ const v = sel.value;
+ if (v) setActiveListId(v);
+ refreshShopping();
+ });
+
+ document.getElementById('shopping-new-list')?.addEventListener('click', () => {
+ const name = window.prompt('Nazwa nowej listy (dowolne zakupy):', 'Nowa lista');
+ if (name === null) return;
+ addFreeformList(name);
+ showAppToast('Utworzono listę.');
+ refreshShopping();
+ });
+
+ document.getElementById('shopping-delete-list')?.addEventListener('click', () => {
+ const list = getActiveList();
+ if (list.id === KITCHEN_LIST_ID) return;
+ if (!window.confirm(`Usunąć listę „${list.name}”?`)) return;
+ deleteList(list.id);
+ showAppToast('Lista usunięta.');
+ refreshShopping();
+ });
+
+ const submitFreeform = () => {
+ const list = getActiveList();
+ if (list.type !== 'freeform') return;
+ const input = document.getElementById('shopping-freeform-input');
+ const note = document.getElementById('shopping-freeform-note');
+ const text = input?.value || '';
+ addFreeformLine(list.id, text, note?.value || '');
+ if (input) input.value = '';
+ if (note) note.value = '';
+ refreshShopping();
+ };
+
+ document.getElementById('shopping-freeform-submit')?.addEventListener('click', submitFreeform);
+ document.getElementById('shopping-freeform-input')?.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ submitFreeform();
+ }
+ });
+
+ refreshShopping();
+ window.refreshShopping = refreshShopping;
+}