699 lines
31 KiB
JavaScript
699 lines
31 KiB
JavaScript
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, startOfWeekMonday } from '../services/dateUtils.js';
|
||
import { showAppToast } from '../ui/toast.js';
|
||
|
||
/* ── helpers ── */
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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 SL_SHADOW = '0 5px 10px rgba(0,0,0,0.16), 0 14px 22px rgba(0,0,0,0.24), 0 22px 34px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.04)';
|
||
|
||
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'];
|
||
|
||
/* ── module state ── */
|
||
let boughtSectionOpen = false;
|
||
let expandedIngredientId = null;
|
||
let expandedAmount = 0;
|
||
let calendarOpen = false;
|
||
let calendarMonth = startOfMonth(new Date());
|
||
|
||
/* ── 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() {
|
||
const weekdayHeader = WEEKDAY_SHORT
|
||
.map((d) => `<div>${d}</div>`)
|
||
.join('');
|
||
|
||
return `
|
||
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:#2d2e2b !important;">
|
||
|
||
<!-- ── header ── -->
|
||
<div class="shrink-0 px-4 pt-5 pb-0">
|
||
|
||
<!-- title row + pill (position:relative anchors the popup) -->
|
||
<div class="flex items-center gap-2 mb-3" style="position:relative;">
|
||
<h1 class="flex-1 text-[18px] font-bold" style="color:#f2efe8;">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:#393937; border:1px solid #41423f; box-shadow:${SL_SHADOW};">
|
||
<span id="sl-range-label" class="min-w-0 flex-1 text-left text-[13px] font-normal truncate" style="color:#ddd6ca;"></span>
|
||
<i id="sl-range-chevron" class="fas fa-chevron-down text-[10px] shrink-0 transition-transform duration-200" style="color:#9b978f;"></i>
|
||
</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] px-3 py-3" style="background:#23221e; border:1px solid #787876; box-shadow:${SL_SHADOW};">
|
||
<!-- month nav topbar -->
|
||
<div class="pb-3 flex items-center justify-end gap-3">
|
||
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center gap-px rounded-full border px-0.5" style="background:#272622; border-color:#34312c;">
|
||
<button type="button" id="sl-cal-prev" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full bg-transparent transition-colors" style="color:#d7d2c8;">
|
||
<i class="fas fa-chevron-left text-[10px]"></i>
|
||
</button>
|
||
<span id="sl-cal-month-label" class="h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center px-1.5 text-[10px] font-semibold leading-none tabular-nums whitespace-nowrap" style="color:#d7d2c8;"></span>
|
||
<button type="button" id="sl-cal-next" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full bg-transparent transition-colors" style="color:#d7d2c8;">
|
||
<i class="fas fa-chevron-right text-[10px]"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- weekday header -->
|
||
<div class="grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium uppercase tracking-wide mb-1 leading-none" style="color:#9b978f;">${weekdayHeader}</div>
|
||
<!-- day grid -->
|
||
<div id="sl-cal-grid" class="grid grid-cols-7 gap-1.5"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- progress -->
|
||
<div id="sl-progress-wrap" class="mb-3">
|
||
<span class="text-[12px] font-semibold block mb-1.5" style="color:#9b978f;">Kupione <span id="sl-progress-text">0/0</span></span>
|
||
<div class="flex items-center gap-2">
|
||
<div class="flex-1 h-1.5 rounded-full overflow-hidden" style="background:#393937;">
|
||
<div id="sl-progress-bar" class="h-full rounded-full transition-all duration-300" style="background:rgb(var(--success-rgb)); width:0%;"></div>
|
||
</div>
|
||
<button type="button" id="sl-clear-session" class="flex items-center justify-center flex-shrink-0" style="color:#6b6965;" aria-label="Wyczyść kupione">
|
||
<i class="fas fa-trash-can" style="font-size:14px;" aria-hidden="true"></i>
|
||
</button>
|
||
</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:#2d2e2b !important;">
|
||
<div id="sl-board"></div>
|
||
|
||
<!-- bought section -->
|
||
<div id="sl-bought-section" class="mt-3 hidden">
|
||
<button type="button" id="sl-bought-toggle" class="flex items-center gap-2 w-full py-2 px-1 text-left" style="color:#9b978f;">
|
||
<i id="sl-bought-chevron" class="fas fa-chevron-right text-[10px] transition-transform duration-200"></i>
|
||
<span class="text-[11px] font-bold uppercase tracking-wider">Kupione (<span id="sl-bought-count">0</span>)</span>
|
||
</button>
|
||
<div id="sl-bought-list" class="hidden mt-1"></div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ══════════════════════ CALENDAR ══════════════════════ */
|
||
|
||
function getDayRange(startDk, endDk) {
|
||
const a = new Date(startDk + 'T00:00:00');
|
||
const b = new Date(endDk + 'T00:00:00');
|
||
const [from, to] = a <= b ? [a, b] : [b, a];
|
||
const days = [];
|
||
for (let d = new Date(from); d <= to; d = addDays(d, 1)) days.push(dateKey(d));
|
||
return days;
|
||
}
|
||
|
||
function renderCalendarGrid(previewDays = null) {
|
||
const grid = document.getElementById('sl-cal-grid');
|
||
const monthLabel = document.getElementById('sl-cal-month-label');
|
||
if (!grid) return;
|
||
|
||
if (monthLabel) {
|
||
monthLabel.textContent = `${MONTHS_LONG[calendarMonth.getMonth()]} ${calendarMonth.getFullYear()}`;
|
||
}
|
||
|
||
const selected = previewDays ?? new Set(getSelectedDays());
|
||
const today = todayKey();
|
||
const first = startOfMonth(calendarMonth);
|
||
const gridStart = startOfWeekMonday(first);
|
||
|
||
const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
|
||
while (cells.length > 35 && cells.slice(-7).every((d) => d.getMonth() !== first.getMonth())) {
|
||
cells.splice(-7);
|
||
}
|
||
|
||
grid.innerHTML = cells.map((day) => {
|
||
const dk = dateKey(day);
|
||
const inMonth = day.getMonth() === first.getMonth();
|
||
const isSel = selected.has(dk);
|
||
const isToday = dk === today;
|
||
|
||
let bg, borderColor, color, opacity, borderClass;
|
||
if (isSel) {
|
||
bg = '#393937'; borderColor = '#787876'; color = '#f2efe8'; opacity = '1'; borderClass = 'border';
|
||
} else if (!inMonth) {
|
||
bg = 'transparent'; borderColor = 'transparent'; color = '#5a5752'; opacity = '0.38'; borderClass = 'border-0';
|
||
} else {
|
||
bg = '#272622'; borderColor = '#34312c'; color = '#d7d2c8'; opacity = '1'; borderClass = 'border';
|
||
}
|
||
|
||
const dot = isToday && !isSel
|
||
? `<span class="absolute left-1/2 -translate-x-1/2 w-1 h-1 rounded-full opacity-75" style="bottom:0.24rem; background:#7d7a74;"></span>`
|
||
: '';
|
||
|
||
return `<button type="button" class="sl-cal-day mx-auto flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full ${borderClass} text-xs font-medium leading-tight overflow-hidden"
|
||
style="background:${bg}; border-color:${borderColor}; color:${color}; opacity:${opacity}; touch-action:none;"
|
||
data-dk="${esc(dk)}">
|
||
<span class="relative flex h-full w-full flex-col items-center justify-center">
|
||
<span class="text-[13px] font-semibold leading-none${dot ? ' -translate-y-[0.18rem]' : ''}">${day.getDate()}</span>
|
||
${dot}
|
||
</span>
|
||
</button>`;
|
||
}).join('');
|
||
}
|
||
|
||
function bindCalendarEvents() {
|
||
const grid = document.getElementById('sl-cal-grid');
|
||
if (!grid) return;
|
||
|
||
let dragStartDk = null;
|
||
let dragCurrentDk = null;
|
||
let dragging = false;
|
||
|
||
grid.addEventListener('pointerdown', (e) => {
|
||
e.stopPropagation();
|
||
const btn = e.target.closest('.sl-cal-day');
|
||
if (!btn) return;
|
||
dragStartDk = btn.dataset.dk;
|
||
dragCurrentDk = btn.dataset.dk;
|
||
dragging = true;
|
||
grid.setPointerCapture(e.pointerId);
|
||
renderCalendarGrid(new Set([dragStartDk]));
|
||
});
|
||
|
||
grid.addEventListener('pointermove', (e) => {
|
||
if (!dragging || !dragStartDk) return;
|
||
e.preventDefault();
|
||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||
const btn = el?.closest('.sl-cal-day');
|
||
if (!btn?.dataset.dk || btn.dataset.dk === dragCurrentDk) return;
|
||
dragCurrentDk = btn.dataset.dk;
|
||
renderCalendarGrid(new Set(getDayRange(dragStartDk, dragCurrentDk)));
|
||
});
|
||
|
||
grid.addEventListener('pointerup', (e) => {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
const range = getDayRange(dragStartDk, dragCurrentDk);
|
||
dragStartDk = null;
|
||
dragCurrentDk = null;
|
||
setSelectedDays(range);
|
||
expandedIngredientId = null;
|
||
updatePillLabel();
|
||
renderCalendarGrid();
|
||
renderAll();
|
||
});
|
||
|
||
grid.addEventListener('pointercancel', () => {
|
||
dragging = false;
|
||
dragStartDk = null;
|
||
dragCurrentDk = null;
|
||
renderCalendarGrid();
|
||
});
|
||
|
||
// Prevent bubbled click events (generated after pointerup) from closing the popup
|
||
grid.addEventListener('click', (e) => e.stopPropagation());
|
||
}
|
||
|
||
function updatePillLabel() {
|
||
const el = document.getElementById('sl-range-label');
|
||
if (el) el.textContent = formatRangePill(getSelectedDays());
|
||
}
|
||
|
||
function openCalendar() {
|
||
calendarOpen = true;
|
||
calendarMonth = startOfMonth(new Date());
|
||
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 = 'auto';
|
||
popup.style.opacity = '1';
|
||
popup.style.transform = 'translateY(0) scale(1)';
|
||
}
|
||
if (chevron) chevron.style.transform = 'rotate(180deg)';
|
||
if (pill) {
|
||
pill.style.background = '#23221e';
|
||
pill.style.borderColor = '#787876';
|
||
}
|
||
renderCalendarGrid();
|
||
}
|
||
|
||
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 = '#393937';
|
||
pill.style.borderColor = '#41423f';
|
||
}
|
||
}
|
||
|
||
function toggleCalendar() {
|
||
calendarOpen ? closeCalendar() : openCalendar();
|
||
}
|
||
|
||
/* ══════════════════════ ITEM ROWS ══════════════════════ */
|
||
|
||
function activeItemHtml(item, isPartial) {
|
||
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:#2f2f2d;"><i class="fas ${icon} text-xs" style="color:#8f8b84;"></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:#4a7c59; box-shadow:0 2px 8px rgba(0,0,0,0.28);">
|
||
<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:#393937; 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:#ddd6ca;">${esc(item.name)}</span>
|
||
${isPartial ? `<span class="text-[10px]" style="color:rgb(var(--accent-rgb));">część już w spiżarni</span>` : ''}
|
||
</div>
|
||
<span class="text-[13px] font-semibold tabular-nums shrink-0" style="color:#b7ada1;">${esc(formatQty(item.shortfall))} ${esc(unitLabel(item.unit))}</span>
|
||
<i class="fas fa-chevron-${isExpanded ? 'up' : 'down'} text-[9px] shrink-0" style="color:#6d6c67;"></i>
|
||
</div>
|
||
<div class="sl-step-row overflow-hidden" data-step-for="${esc(item.ingredientId)}"
|
||
style="max-height:${isExpanded ? '52px' : '0'}; transition: max-height 0.2s ease;">
|
||
<div class="flex items-center gap-3 px-3 pb-3">
|
||
<button type="button" class="sl-step-minus w-8 h-8 rounded-full flex items-center justify-center text-lg font-bold shrink-0 active:scale-95" style="background:#2f2f2d; color:#ddd6ca; border:1px solid #444442;">−</button>
|
||
<span class="sl-exp-amount text-[15px] font-bold tabular-nums" style="color:#f2efe8; min-width:40px; text-align:center;">${esc(formatQty(stepAmt))}</span>
|
||
<span class="text-[12px]" style="color:#9b978f;">${esc(unitLabel(item.unit))}</span>
|
||
<button type="button" class="sl-step-plus w-8 h-8 rounded-full flex items-center justify-center text-lg font-bold shrink-0 active:scale-95" style="background:#2f2f2d; color:#ddd6ca; border:1px solid #444442;">+</button>
|
||
<div class="flex-1"></div>
|
||
<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(--accent-rgb)); color:#1a1a1a;">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 opacity-50">`
|
||
: `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 opacity-50" style="background:#2f2f2d;"><i class="fas ${icon} text-xs" style="color:#8f8b84;"></i></div>`;
|
||
|
||
return `
|
||
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="background:#7c4a4a; box-shadow:0 2px 8px rgba(0,0,0,0.28);">
|
||
<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:#2f2f2d; position:relative;" data-entry-id="${esc(entry.id)}">
|
||
<div class="shrink-0 w-6 h-6 rounded-full flex items-center justify-center" style="background:rgb(var(--success-rgb));">
|
||
<i class="fas fa-check text-[10px]" style="color:#1a1a1a;"></i>
|
||
</div>
|
||
${mediaHtml}
|
||
<div class="flex-1 min-w-0 opacity-60">
|
||
<span class="block text-[13px] font-medium leading-tight truncate line-through" style="color:#ddd6ca;">${esc(entry.name)}</span>
|
||
</div>
|
||
<span class="text-[13px] tabular-nums shrink-0 opacity-60" style="color:#b7ada1;">${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 renderProgress(activeItems, sessionLog) {
|
||
const activeIds = new Set(activeItems.map((i) => i.ingredientId));
|
||
const coveredIds = new Set(sessionLog.filter((e) => !activeIds.has(e.ingredientId)).map((e) => e.ingredientId));
|
||
const bought = coveredIds.size;
|
||
const total = activeItems.length + bought;
|
||
|
||
const textEl = document.getElementById('sl-progress-text');
|
||
const barEl = document.getElementById('sl-progress-bar');
|
||
if (textEl) textEl.textContent = `${bought}/${total}`;
|
||
if (barEl) barEl.style.width = total > 0 ? `${Math.round((bought / total) * 100)}%` : '0%';
|
||
}
|
||
|
||
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:#393937;">
|
||
<i class="fas fa-calendar-days text-xl" style="color:#6d6c67;"></i>
|
||
</div>
|
||
<p class="text-[14px] font-semibold mb-1" style="color:#ddd6ca;">Wybierz dni</p>
|
||
<p class="text-[12px] max-w-[14rem]" style="color:#9b978f;">Otwórz kalendarz i zaznacz dni, na które chcesz zrobić zakupy.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
const activeItems = computeActiveItems();
|
||
const sessionLog = getSessionLog();
|
||
const sessionIds = new Set(sessionLog.map((e) => e.ingredientId));
|
||
|
||
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:#393937;">
|
||
<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:#ddd6ca;">Wszystko masz</p>
|
||
<p class="text-[12px] max-w-[14rem]" style="color:#9b978f;">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:#9b978f;"></i>
|
||
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:#9b978f;">${esc(categoryLabel(cat))}</p>
|
||
<span class="text-[10px]" style="color:#6d6c67;">${catItems.length}</span>
|
||
</div>
|
||
${catItems.map((item) => activeItemHtml(item, sessionIds.has(item.ingredientId))).join('')}
|
||
</section>`;
|
||
}).join('');
|
||
|
||
bindActiveRowEvents(root, activeItems);
|
||
}
|
||
|
||
function renderBought() {
|
||
const sessionLog = getSessionLog();
|
||
const section = document.getElementById('sl-bought-section');
|
||
const countEl = document.getElementById('sl-bought-count');
|
||
const list = document.getElementById('sl-bought-list');
|
||
if (!section || !list || !countEl) return;
|
||
|
||
if (sessionLog.length === 0) { section.classList.add('hidden'); return; }
|
||
|
||
section.classList.remove('hidden');
|
||
countEl.textContent = String(sessionLog.length);
|
||
|
||
const chevron = document.getElementById('sl-bought-chevron');
|
||
if (chevron) chevron.style.transform = boughtSectionOpen ? 'rotate(90deg)' : '';
|
||
|
||
if (!boughtSectionOpen) { list.classList.add('hidden'); return; }
|
||
|
||
list.classList.remove('hidden');
|
||
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 activeItems = computeActiveItems();
|
||
const sessionLog = getSessionLog();
|
||
renderProgress(activeItems, sessionLog);
|
||
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();
|
||
}
|
||
|
||
export function setupShoppingList() {
|
||
if (getSelectedDays().length === 0) setSelectedDays(getDefaultSelectedDays());
|
||
|
||
updatePillLabel();
|
||
renderAll();
|
||
|
||
bindCalendarEvents();
|
||
|
||
document.getElementById('sl-range-pill')?.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
toggleCalendar();
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
if (!calendarOpen) return;
|
||
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();
|
||
}
|
||
});
|
||
|
||
document.getElementById('sl-cal-prev')?.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
calendarMonth = startOfMonth(new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1, 1));
|
||
renderCalendarGrid();
|
||
});
|
||
|
||
document.getElementById('sl-cal-next')?.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
calendarMonth = startOfMonth(new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1, 1));
|
||
renderCalendarGrid();
|
||
});
|
||
|
||
|
||
document.getElementById('sl-clear-session')?.addEventListener('click', () => {
|
||
clearSessionLog();
|
||
renderAll();
|
||
});
|
||
|
||
document.getElementById('sl-bought-toggle')?.addEventListener('click', () => {
|
||
boughtSectionOpen = !boughtSectionOpen;
|
||
renderBought();
|
||
});
|
||
|
||
window.refreshShoppingList = refreshShoppingList;
|
||
}
|