All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m15s
658 lines
29 KiB
JavaScript
658 lines
29 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 } 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, '&').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 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: {
|
||
selectedBg: 'rgb(var(--card-rgb))',
|
||
selectedBorder: 'rgb(var(--border-input-rgb))',
|
||
selectedText: 'rgb(var(--text-emphasis-rgb))',
|
||
selectedDot: 'rgb(var(--text-emphasis-rgb))',
|
||
bg: 'rgb(var(--app-bg-rgb))',
|
||
border: 'rgb(var(--card-raised-rgb))',
|
||
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?.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;
|
||
}
|