Files
recipe-mockup/js/views/ShoppingList.js
2026-04-20 23:04:28 +02:00

659 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { INGREDIENTS } from '../data/catalog.js?v=9';
import {
loadPantry,
computeShortfalls,
categoryLabel,
addAmountToPantry,
subtractFromPantry,
getSelectedDays,
setSelectedDays,
addSessionLogEntry,
removeSessionLogEntry,
clearSessionLog,
loadShoppingSession,
} from '../services/pantryShopping.js?v=2';
import { aggregateSelectedDaysIngredientNeed } from '../services/planIngredients.js?v=2';
import { loadPlans, dateKey } from '../services/planStore.js?v=2';
import { addDays, startOfDay, startOfMonth } from '../services/dateUtils.js';
import { createSwipePopoverCalendarHTML, initSwipePopoverCalendar } from '../ui/swipePopoverCalendar.js';
import { showAppToast } from '../ui/toast.js';
/* ── helpers ── */
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatQty(n) {
const rounded = Math.round((Number(n) || 0) * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, '');
}
function unitLabel(u) {
return u === 'szt' ? 'szt.' : String(u);
}
function stepForUnit(unit) {
return (unit === 'szt.' || unit === 'szt') ? 1 : 10;
}
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',
};
const CATEGORY_ORDER = ['pieczywo', 'nabial', 'mieso_ryby', 'warzywa', 'owoce', 'suche', 'przyprawy', 'inne'];
const DAY_ABBR = ['Nd', 'Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'Sb'];
const DAY_NAMES_SHORT = ['nd.', 'pon.', 'wt.', 'śr.', 'czw.', 'pt.', 'sob.'];
const WEEKDAY_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'sb', 'nd'];
const MONTHS_LONG = ['Styczeń','Luty','Marzec','Kwiecień','Maj','Czerwiec','Lipiec','Sierpień','Wrzesień','Październik','Listopad','Grudzień'];
const MONTHS_SHORT = ['sty','lut','mar','kwi','maj','cze','lip','sie','wrz','paź','lis','gru'];
const CALENDAR_DIM_TEXT = 'rgb(var(--text-faint-rgb))';
const CALENDAR_DIM_OPACITY = '0.58';
/* ── module state ── */
let boughtPopupOpen = false;
let expandedIngredientId = null;
let expandedAmount = 0;
let calendarOpen = false;
let calendarMonth = startOfMonth(new Date());
let shoppingCalendar = null;
/* ── day helpers ── */
function todayKey() { return dateKey(new Date()); }
function getDefaultSelectedDays() {
const today = startOfDay(new Date());
return Array.from({ length: 7 }, (_, i) => dateKey(addDays(today, i)));
}
function formatRangePill(selectedDays) {
if (selectedDays.length === 0) return 'Wybierz dni';
const sorted = [...selectedDays].sort();
const fmt = (dk) => {
const d = new Date(dk + 'T00:00:00');
return `${DAY_NAMES_SHORT[d.getDay()]} ${d.getDate()} ${MONTHS_SHORT[d.getMonth()]}`;
};
if (sorted.length === 1) return fmt(sorted[0]);
return `${fmt(sorted[0])} ${fmt(sorted[sorted.length - 1])}`;
}
/* ── computed data ── */
function computeActiveItems() {
const selectedDays = getSelectedDays();
if (selectedDays.length === 0) return [];
const plans = loadPlans();
const needLines = aggregateSelectedDaysIngredientNeed(plans, selectedDays);
return computeShortfalls(needLines, loadPantry());
}
function getSessionLog() {
return loadShoppingSession().sessionLog;
}
function groupByCategory(items) {
const groups = new Map();
for (const item of items) {
const cat = item.category || 'inne';
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat).push(item);
}
return CATEGORY_ORDER
.filter((cat) => groups.has(cat))
.map((cat) => ({ cat, items: groups.get(cat) }));
}
/* ══════════════════════ HTML SHELL ══════════════════════ */
export function getShoppingListHTML() {
return `
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:rgb(var(--app-bg-rgb)) !important;">
<!-- ── header ── -->
<div class="shrink-0 px-4 pt-5 pb-0">
<!-- title row + pill + bought button (position:relative anchors the popups) -->
<div class="flex items-center gap-2 mb-4" style="position:relative;">
<h1 class="flex-1 text-[18px] font-bold" style="color:rgb(var(--text-emphasis-rgb));">Lista zakupów</h1>
<button type="button" id="sl-range-pill" class="min-w-0 max-w-[10rem] h-10 rounded-full flex items-center gap-1.5 px-2.5 transition-all shrink" style="background:rgb(var(--card-rgb)); border:1px solid rgb(var(--border-card-rgb)); box-shadow:var(--shadow-shell);">
<span id="sl-range-label" class="min-w-0 flex-1 text-left text-[13px] font-normal truncate" style="color:rgb(var(--text-body-rgb));"></span>
<i id="sl-range-chevron" class="fas fa-chevron-down text-[10px] shrink-0 transition-transform duration-200" style="color:rgb(var(--text-dim-rgb));"></i>
</button>
<button type="button" id="sl-bought-btn" class="relative h-10 w-10 rounded-full flex items-center justify-center transition-all shrink-0" style="background:rgb(var(--card-rgb)); border:1px solid rgb(var(--border-card-rgb)); box-shadow:var(--shadow-shell);" aria-label="Kupione">
<i class="fas fa-check text-[13px]" style="color:rgb(var(--text-body-rgb));"></i>
<span id="sl-bought-badge" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full text-[9px] font-bold items-center justify-center" style="background:rgb(var(--success-rgb)); color:rgb(var(--on-accent-rgb)); display:none;">0</span>
</button>
<!-- popup calendar (absolute, overlays content below) -->
<div id="sl-calendar-popup" style="position:absolute; top:calc(100% + 0.5rem); left:0; right:0; z-index:50; pointer-events:none; opacity:0; transform:translateY(-6px) scale(0.98); transition: opacity 0.2s ease, transform 0.2s ease;">
<div class="rounded-[1.35rem] py-3" style="background:rgb(var(--sunken-rgb)); border:1px solid rgb(var(--border-input-rgb)); box-shadow:var(--shadow-shell);">
${createSwipePopoverCalendarHTML({
idPrefix: 'sl-cal',
weekdays: WEEKDAY_SHORT,
})}
</div>
</div>
<!-- popup bought (absolute, overlays content below) -->
<div id="sl-bought-popup" style="position:absolute; top:calc(100% + 0.5rem); left:0; right:0; z-index:50; pointer-events:none; opacity:0; transform:translateY(-6px) scale(0.98); transition: opacity 0.2s ease, transform 0.2s ease;">
<div class="rounded-[1.35rem] px-3 py-3" style="background:rgb(var(--sunken-rgb)); border:1px solid rgb(var(--border-input-rgb)); box-shadow:var(--shadow-shell);">
<div class="flex items-center justify-between mb-2 px-1">
<span class="text-[11px] font-bold uppercase tracking-wider" style="color:rgb(var(--text-dim-rgb));">Kupione (<span id="sl-bought-popup-count">0</span>)</span>
<button type="button" id="sl-clear-session" class="h-8 px-2 rounded-full text-[11px] font-semibold transition-colors" style="background:transparent; border:none; color:rgb(var(--text-muted-rgb));">
Wyczyść
</button>
</div>
<div id="sl-bought-list" class="max-h-[50vh] overflow-y-auto no-scrollbar"></div>
</div>
</div>
</div>
</div>
<!-- ── scrollable list ── -->
<div id="sl-scroll" class="flex-1 overflow-y-auto no-scrollbar px-4 pt-3 pb-24" style="background:rgb(var(--app-bg-rgb)) !important;">
<div id="sl-board"></div>
</div>
</div>`;
}
/* ══════════════════════ CALENDAR ══════════════════════ */
function initShoppingCalendar() {
shoppingCalendar = initSwipePopoverCalendar({
idPrefix: 'sl-cal',
selectionMode: 'range',
monthsLong: MONTHS_LONG,
getMonthAnchor: () => calendarMonth,
setMonthAnchor: (nextMonth) => {
calendarMonth = startOfMonth(nextMonth);
},
getSelectionKeys: () => getSelectedDays(),
onSelectionCommit: (rangeKeys) => {
setSelectedDays(rangeKeys);
expandedIngredientId = null;
updatePillLabel();
renderAll();
},
resolveDayState: (day, { inCurrentMonth, isSelected }) => ({
disabled: false,
dimmed: !inCurrentMonth,
showDot: dateKey(day) === todayKey() && !isSelected,
}),
theme: {
selectedBorder: 'rgba(var(--text-emphasis-rgb),0.34)',
selectedText: 'rgb(var(--text-emphasis-rgb))',
selectedDot: 'rgb(var(--text-emphasis-rgb))',
selectedShadow: '0 0 0 1px rgba(var(--text-emphasis-rgb),0.10)',
bg: 'rgb(var(--app-bg-rgb))',
border: 'transparent',
text: 'rgb(var(--text-body-soft-rgb))',
dimmedBg: 'transparent',
dimText: CALENDAR_DIM_TEXT,
dimOpacity: Number(CALENDAR_DIM_OPACITY),
dot: 'rgb(var(--text-faint-rgb))',
},
});
}
function updatePillLabel() {
const el = document.getElementById('sl-range-label');
if (el) el.textContent = formatRangePill(getSelectedDays());
}
function openCalendar() {
if (boughtPopupOpen) closeBoughtPopup();
calendarOpen = true;
calendarMonth = startOfMonth(new Date());
const popup = document.getElementById('sl-calendar-popup');
const viewport = document.getElementById('sl-cal-viewport');
const chevron = document.getElementById('sl-range-chevron');
const pill = document.getElementById('sl-range-pill');
if (popup) {
popup.style.pointerEvents = 'auto';
popup.style.opacity = '1';
popup.style.transform = 'translateY(0) scale(1)';
}
if (chevron) chevron.style.transform = 'rotate(180deg)';
if (pill) {
pill.style.background = 'rgb(var(--sunken-rgb))';
pill.style.borderColor = 'rgb(var(--border-input-rgb))';
}
if (viewport) {
viewport.style.opacity = '0';
viewport.style.visibility = 'hidden';
viewport.style.transition = 'opacity 120ms ease';
}
shoppingCalendar?.render();
// Compute geometry while hidden; reveal only after stable layout.
const ensureStableCalendarLayout = (attempt = 0) => {
const vw = viewport ? (viewport.clientWidth || viewport.getBoundingClientRect().width) : 0;
if (vw < 8 && attempt < 8) {
requestAnimationFrame(() => ensureStableCalendarLayout(attempt + 1));
return;
}
shoppingCalendar?.reapplyLayout();
shoppingCalendar?.resetTrackPosition();
requestAnimationFrame(() => {
shoppingCalendar?.reapplyLayout();
shoppingCalendar?.resetTrackPosition();
if (viewport) {
viewport.style.visibility = 'visible';
viewport.style.opacity = '1';
}
});
};
requestAnimationFrame(() => ensureStableCalendarLayout());
}
function closeCalendar() {
calendarOpen = false;
const popup = document.getElementById('sl-calendar-popup');
const chevron = document.getElementById('sl-range-chevron');
const pill = document.getElementById('sl-range-pill');
if (popup) {
popup.style.pointerEvents = 'none';
popup.style.opacity = '0';
popup.style.transform = 'translateY(-6px) scale(0.98)';
}
if (chevron) chevron.style.transform = '';
if (pill) {
pill.style.background = 'rgb(var(--card-rgb))';
pill.style.borderColor = 'rgb(var(--border-card-rgb))';
}
shoppingCalendar?.clearPendingRange?.();
shoppingCalendar?.resetTrackPosition();
}
function toggleCalendar() {
calendarOpen ? closeCalendar() : openCalendar();
}
function openBoughtPopup() {
if (calendarOpen) closeCalendar();
boughtPopupOpen = true;
const popup = document.getElementById('sl-bought-popup');
const btn = document.getElementById('sl-bought-btn');
if (popup) {
popup.style.pointerEvents = 'auto';
popup.style.opacity = '1';
popup.style.transform = 'translateY(0) scale(1)';
}
if (btn) {
btn.style.background = 'rgb(var(--sunken-rgb))';
btn.style.borderColor = 'rgb(var(--border-input-rgb))';
}
}
function closeBoughtPopup() {
boughtPopupOpen = false;
const popup = document.getElementById('sl-bought-popup');
const btn = document.getElementById('sl-bought-btn');
if (popup) {
popup.style.pointerEvents = 'none';
popup.style.opacity = '0';
popup.style.transform = 'translateY(-6px) scale(0.98)';
}
if (btn) {
btn.style.background = 'rgb(var(--card-rgb))';
btn.style.borderColor = 'rgb(var(--border-card-rgb))';
}
}
function toggleBoughtPopup() {
boughtPopupOpen ? closeBoughtPopup() : openBoughtPopup();
}
/* ══════════════════════ ITEM ROWS ══════════════════════ */
function activeItemHtml(item) {
const def = INGREDIENTS[item.ingredientId];
const icon = CATEGORY_ICONS[def?.category] || 'fa-jar';
const image = def?.image;
const mediaFit = image?.endsWith('.svg') ? 'object-contain' : 'object-cover';
const mediaHtml = image
? `<img src="${esc(image)}" alt="" class="w-8 h-8 rounded-lg ${mediaFit} shrink-0">`
: `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background:rgb(var(--card-soft-rgb));"><i class="fas ${icon} text-xs" style="color:rgb(var(--text-faint-rgb));"></i></div>`;
const isExpanded = expandedIngredientId === item.ingredientId;
const step = stepForUnit(item.unit);
const stepAmt = isExpanded ? expandedAmount : Math.max(step, Math.round(item.shortfall / step) * step);
return `
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="background:rgb(var(--success-rgb)); box-shadow:var(--shadow-card);">
<div class="sl-swipe-bg-buy absolute inset-0 flex items-center pr-5 justify-end">
<i class="fas fa-check text-white text-lg"></i>
</div>
<div class="sl-swipe-inner rounded-xl" style="background:rgb(var(--card-rgb)); position:relative;"
data-id="${esc(item.ingredientId)}" data-unit="${esc(item.unit)}" data-shortfall="${item.shortfall}">
<div class="sl-item-main flex items-center gap-3 py-1.5 px-3 cursor-pointer select-none">
${mediaHtml}
<div class="flex-1 min-w-0">
<span class="block text-[13px] font-medium leading-tight truncate" style="color:rgb(var(--text-body-rgb));">${esc(item.name)}</span>
</div>
<span class="text-[13px] font-semibold tabular-nums shrink-0" style="color:rgb(var(--text-muted-rgb));">${esc(formatQty(item.shortfall))} ${esc(unitLabel(item.unit))}</span>
<i class="fas fa-chevron-${isExpanded ? 'up' : 'down'} text-[9px] shrink-0" style="color:rgb(var(--text-subdued-rgb));"></i>
</div>
<div class="sl-step-row overflow-hidden" data-step-for="${esc(item.ingredientId)}"
style="max-height:${isExpanded ? '60px' : '0'}; transition: max-height 0.2s ease;">
<div class="flex items-center gap-2 px-3 pb-3">
<button type="button" class="sl-step-minus w-9 h-9 rounded-xl flex items-center justify-center shrink-0 active:scale-95" style="background:rgb(var(--card-soft-rgb)); color:rgb(var(--text-body-soft-rgb));" aria-label="Zmniejsz ilość">
<i class="fas fa-minus text-xs"></i>
</button>
<div class="flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2" style="background:rgb(var(--card-soft-rgb));">
<span class="sl-exp-amount text-[14px] font-semibold tabular-nums" style="color:rgb(var(--text-body-rgb));">${esc(formatQty(stepAmt))}</span>
<span class="text-[12px] font-medium shrink-0" style="color:rgb(var(--text-dim-rgb));">${esc(unitLabel(item.unit))}</span>
</div>
<button type="button" class="sl-step-plus w-9 h-9 rounded-xl flex items-center justify-center shrink-0 active:scale-95" style="background:rgb(var(--card-soft-rgb)); color:rgb(var(--text-body-soft-rgb));" aria-label="Zwiększ ilość">
<i class="fas fa-plus text-xs"></i>
</button>
<button type="button" class="sl-step-confirm px-3 py-1.5 rounded-lg text-[12px] font-semibold shrink-0 active:scale-95" style="background:rgb(var(--text-body-rgb)); color:rgb(var(--app-bg-rgb));">Dodaj</button>
</div>
</div>
</div>
</div>`;
}
function boughtItemHtml(entry) {
const def = INGREDIENTS[entry.ingredientId];
const icon = CATEGORY_ICONS[def?.category] || 'fa-jar';
const image = def?.image;
const mediaFit = image?.endsWith('.svg') ? 'object-contain' : 'object-cover';
const mediaHtml = image
? `<img src="${esc(image)}" alt="" class="w-8 h-8 rounded-lg ${mediaFit} shrink-0">`
: `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background:rgb(var(--card-soft-rgb));"><i class="fas ${icon} text-xs" style="color:rgb(var(--text-faint-rgb));"></i></div>`;
return `
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="background:rgb(var(--danger-rgb)); box-shadow:var(--shadow-card);">
<div class="sl-swipe-bg-undo absolute inset-0 flex items-center pl-5 justify-start">
<i class="fas fa-rotate-left text-white text-lg"></i>
</div>
<div class="sl-swipe-inner flex items-center gap-3 py-1.5 px-3 rounded-xl" style="background:rgb(var(--card-rgb)); position:relative;" data-entry-id="${esc(entry.id)}">
${mediaHtml}
<div class="flex-1 min-w-0">
<span class="block text-[13px] font-medium leading-tight truncate" style="color:rgb(var(--text-body-rgb));">${esc(entry.name)}</span>
</div>
<span class="text-[13px] font-semibold tabular-nums shrink-0" style="color:rgb(var(--text-muted-rgb));">${esc(formatQty(entry.addedAmount))} ${esc(unitLabel(entry.unit))}</span>
</div>
</div>`;
}
/* ══════════════════════ SWIPE ══════════════════════ */
function attachSwipe(container, opts) {
const inner = container.querySelector('.sl-swipe-inner');
if (!inner) return;
let startX = 0, startY = 0, dx = 0, tracking = false, decided = false, goingH = false;
container.addEventListener('pointerdown', (e) => {
startX = e.clientX; startY = e.clientY; dx = 0;
tracking = true; decided = false; goingH = false;
inner.style.transition = 'none';
container.setPointerCapture(e.pointerId);
});
container.addEventListener('pointermove', (e) => {
if (!tracking) return;
const ddx = e.clientX - startX;
const ddy = e.clientY - startY;
if (!decided) {
if (Math.abs(ddx) < 6 && Math.abs(ddy) < 6) return;
decided = true;
goingH = Math.abs(ddx) > Math.abs(ddy);
}
if (!goingH) { tracking = false; inner.style.transform = ''; return; }
dx = ddx;
if (dx > 0 && opts.onRight) inner.style.transform = `translateX(${Math.min(dx, 90)}px)`;
else if (dx < 0 && opts.onLeft) inner.style.transform = `translateX(${Math.max(dx, -90)}px)`;
});
const finish = () => {
if (!tracking) return;
tracking = false;
inner.style.transition = 'transform 0.2s ease';
const thr = 65;
if (dx > thr && opts.onRight) {
inner.style.transform = 'translateX(120%)';
setTimeout(opts.onRight, 180);
} else if (dx < -thr && opts.onLeft) {
inner.style.transform = 'translateX(-120%)';
setTimeout(opts.onLeft, 180);
} else {
inner.style.transform = '';
}
dx = 0;
};
container.addEventListener('pointerup', finish);
container.addEventListener('pointercancel', () => {
tracking = false;
inner.style.transition = 'transform 0.2s ease';
inner.style.transform = '';
dx = 0;
});
}
/* ══════════════════════ EXPAND / STEPPER ══════════════════════ */
function toggleExpand(ingredientId, unit, shortfall) {
if (expandedIngredientId === ingredientId) {
expandedIngredientId = null;
} else {
expandedIngredientId = ingredientId;
const step = stepForUnit(unit);
expandedAmount = Math.max(step, Math.round(shortfall / step) * step);
}
renderBoard();
}
function updateExpandedAmountDisplay() {
const el = document.querySelector(`[data-step-for="${expandedIngredientId}"] .sl-exp-amount`);
if (el) el.textContent = formatQty(expandedAmount);
}
/* ══════════════════════ BUY / UNDO ══════════════════════ */
function buyItem(ingredientId, unit, amount) {
const def = INGREDIENTS[ingredientId];
if (!def) return;
addAmountToPantry(ingredientId, undefined, amount);
addSessionLogEntry({ ingredientId, name: def.name, addedAmount: amount, unit, category: def.category || 'inne' });
expandedIngredientId = null;
renderAll();
if (typeof window.refreshPantry === 'function') window.refreshPantry();
}
function undoBoughtEntry(entryId) {
const log = getSessionLog();
const entry = log.find((e) => e.id === entryId);
if (!entry) return;
subtractFromPantry(entry.ingredientId, entry.productId, entry.addedAmount);
removeSessionLogEntry(entryId);
renderAll();
if (typeof window.refreshPantry === 'function') window.refreshPantry();
showAppToast('Cofnięto zakup');
}
/* ══════════════════════ RENDERING ══════════════════════ */
function renderBoughtBadge(count) {
const badge = document.getElementById('sl-bought-badge');
if (!badge) return;
if (count > 0) {
badge.textContent = String(count);
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
function renderBoard() {
const root = document.getElementById('sl-board');
if (!root) return;
const selectedDays = getSelectedDays();
if (selectedDays.length === 0) {
root.innerHTML = `
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center mb-4" style="background:rgb(var(--card-rgb));">
<i class="fas fa-calendar-days text-xl" style="color:rgb(var(--text-subdued-rgb));"></i>
</div>
<p class="text-[14px] font-semibold mb-1" style="color:rgb(var(--text-body-rgb));">Wybierz dni</p>
<p class="text-[12px] max-w-[14rem]" style="color:rgb(var(--text-dim-rgb));">Otwórz kalendarz i zaznacz dni, na które chcesz zrobić zakupy.</p>
</div>`;
return;
}
const activeItems = computeActiveItems();
if (activeItems.length === 0) {
root.innerHTML = `
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center mb-4" style="background:rgb(var(--card-rgb));">
<i class="fas fa-check text-xl" style="color:rgb(var(--success-rgb));"></i>
</div>
<p class="text-[14px] font-semibold mb-1" style="color:rgb(var(--text-body-rgb));">Wszystko masz</p>
<p class="text-[12px] max-w-[14rem]" style="color:rgb(var(--text-dim-rgb));">Spiżarnia pokrywa zapotrzebowanie na wybrane dni.</p>
</div>`;
return;
}
const groups = groupByCategory(activeItems);
root.innerHTML = groups.map(({ cat, items: catItems }) => {
const icon = CATEGORY_ICONS[cat] || 'fa-jar';
return `
<section class="mb-4">
<div class="flex items-center gap-1.5 mb-2 px-1">
<i class="fas ${icon} text-[10px]" style="color:rgb(var(--text-dim-rgb));"></i>
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:rgb(var(--text-dim-rgb));">${esc(categoryLabel(cat))}</p>
<span class="text-[10px]" style="color:rgb(var(--text-subdued-rgb));">${catItems.length}</span>
</div>
${catItems.map((item) => activeItemHtml(item)).join('')}
</section>`;
}).join('');
bindActiveRowEvents(root, activeItems);
}
function renderBought() {
const sessionLog = getSessionLog();
const countEl = document.getElementById('sl-bought-popup-count');
const list = document.getElementById('sl-bought-list');
if (!list || !countEl) return;
countEl.textContent = String(sessionLog.length);
if (sessionLog.length === 0) {
list.innerHTML = `<div class="py-6 text-center text-[12px]" style="color:rgb(var(--text-dim-rgb));">Brak kupionych</div>`;
return;
}
list.innerHTML = [...sessionLog].reverse().map((e) => boughtItemHtml(e)).join('');
list.querySelectorAll('.sl-swipe-wrap').forEach((wrap) => {
const entryId = wrap.querySelector('.sl-swipe-inner')?.dataset.entryId;
if (!entryId) return;
attachSwipe(wrap, { onLeft: () => undoBoughtEntry(entryId) });
});
}
function renderAll() {
const sessionLog = getSessionLog();
renderBoughtBadge(sessionLog.length);
renderBoard();
renderBought();
}
/* ══════════════════════ ACTIVE ROW EVENTS ══════════════════════ */
function bindActiveRowEvents(root, activeItems) {
root.querySelectorAll('.sl-swipe-wrap').forEach((wrap) => {
const inner = wrap.querySelector('.sl-swipe-inner');
const ingredientId = inner?.dataset.id;
const unit = inner?.dataset.unit;
const shortfall = parseFloat(inner?.dataset.shortfall || '0');
if (!ingredientId) return;
const item = activeItems.find((i) => i.ingredientId === ingredientId);
attachSwipe(wrap, { onRight: item ? () => buyItem(item.ingredientId, item.unit, item.shortfall) : undefined });
inner.querySelector('.sl-item-main')?.addEventListener('click', () => toggleExpand(ingredientId, unit, shortfall));
inner.querySelector('.sl-step-minus')?.addEventListener('click', (e) => {
e.stopPropagation();
const step = stepForUnit(unit);
expandedAmount = Math.max(step, expandedAmount - step);
updateExpandedAmountDisplay();
});
inner.querySelector('.sl-step-plus')?.addEventListener('click', (e) => {
e.stopPropagation();
expandedAmount += stepForUnit(unit);
updateExpandedAmountDisplay();
});
inner.querySelector('.sl-step-confirm')?.addEventListener('click', (e) => {
e.stopPropagation();
if (expandedAmount > 0) buyItem(ingredientId, unit, expandedAmount);
});
});
}
/* ══════════════════════ PUBLIC API ══════════════════════ */
export function refreshShoppingList() {
updatePillLabel();
renderAll();
if (calendarOpen) shoppingCalendar?.render();
}
export function setupShoppingList() {
if (getSelectedDays().length === 0) setSelectedDays(getDefaultSelectedDays());
updatePillLabel();
renderAll();
initShoppingCalendar();
document.getElementById('sl-range-pill')?.addEventListener('click', (e) => {
e.stopPropagation();
toggleCalendar();
});
document.getElementById('sl-bought-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
toggleBoughtPopup();
});
document.addEventListener('click', (e) => {
if (calendarOpen) {
const popup = document.getElementById('sl-calendar-popup');
const pill = document.getElementById('sl-range-pill');
if (popup && !popup.contains(e.target) && pill && !pill.contains(e.target)) {
closeCalendar();
}
}
if (boughtPopupOpen) {
const popup = document.getElementById('sl-bought-popup');
const btn = document.getElementById('sl-bought-btn');
if (popup && !popup.contains(e.target) && btn && !btn.contains(e.target)) {
closeBoughtPopup();
}
}
});
document.getElementById('sl-clear-session')?.addEventListener('click', (e) => {
e.stopPropagation();
clearSessionLog();
renderAll();
});
window.refreshShoppingList = refreshShoppingList;
}