413 lines
19 KiB
JavaScript
413 lines
19 KiB
JavaScript
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, '>')
|
||
.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 `
|
||
<div id="pantry-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-gray-50 z-10 pb-24">
|
||
<div class="shrink-0 bg-white border-b border-gray-200 mt-3 px-4 pt-2 pb-3 space-y-3">
|
||
<div class="flex items-center w-full border border-gray-300 rounded-xl bg-white focus-within:border-gray-400 transition-colors">
|
||
<span class="pl-3 text-gray-400"><i class="fas fa-search text-sm"></i></span>
|
||
<input type="search" id="pantry-search" autocomplete="off" placeholder="Szukaj po nazwie…" class="flex-1 py-2.5 px-2 bg-transparent outline-none text-sm text-gray-800 placeholder-gray-400" />
|
||
</div>
|
||
<div id="pantry-category-filters" class="flex gap-1.5 overflow-x-auto no-scrollbar pb-0.5 -mx-1 px-1"></div>
|
||
</div>
|
||
<div id="pantry-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-4 no-scrollbar">
|
||
<div id="pantry-results" class="space-y-2"></div>
|
||
</div>
|
||
|
||
<div id="pantry-shop-backdrop" class="absolute inset-0 z-[38] bg-black/40 hidden opacity-0 transition-opacity duration-200" aria-hidden="true"></div>
|
||
<div id="pantry-shop-sheet" class="absolute left-0 right-0 z-[40] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] px-4 pt-2 pb-5 flex flex-col gap-3 max-h-[55%] min-h-0" style="bottom: ${PANTRY_SHOP_BOTTOM}; transform: ${PANTRY_SHOP_OFF}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="pantry-shop-heading" aria-modal="true">
|
||
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto shrink-0" aria-hidden="true"></div>
|
||
<div class="shrink-0">
|
||
<h2 id="pantry-shop-heading" class="text-lg font-bold text-gray-900 leading-tight"></h2>
|
||
<p id="pantry-shop-sub" class="text-xs text-gray-500 mt-1"></p>
|
||
</div>
|
||
<div class="flex items-center justify-center gap-4 py-2 shrink-0">
|
||
<button type="button" id="pantry-shop-minus" class="w-11 h-11 rounded-xl bg-gray-100 text-gray-800 hover:bg-gray-200 flex items-center justify-center transition-colors" aria-label="Mniej na liście"><i class="fas fa-minus text-sm"></i></button>
|
||
<input type="number" id="pantry-shop-qty" min="1" step="1" inputmode="numeric" class="w-24 text-center text-2xl font-bold tabular-nums border border-gray-200 rounded-xl py-2 outline-none focus:border-gray-400" value="1" />
|
||
<button type="button" id="pantry-shop-plus" class="w-11 h-11 rounded-xl bg-gray-100 text-gray-800 hover:bg-gray-200 flex items-center justify-center transition-colors" aria-label="Więcej na liście"><i class="fas fa-plus text-sm"></i></button>
|
||
</div>
|
||
<button type="button" id="pantry-shop-add" class="shrink-0 w-full py-3.5 rounded-xl bg-gray-900 text-white text-sm font-semibold hover:bg-black transition-colors">Dodaj na listę</button>
|
||
<button type="button" id="pantry-shop-cancel" class="shrink-0 w-full py-2 text-sm font-medium text-gray-500 hover:text-gray-800">Anuluj</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 `<button type="button" data-pantry-cat="${escapeHtml(c.id)}" class="pantry-cat-btn ${cls}">${escapeHtml(c.label)}</button>`;
|
||
}).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
|
||
? `<span class="shrink-0 w-8 h-8 rounded-xl bg-gray-100 flex items-center justify-center text-gray-600" aria-hidden="true"><i class="fas fa-chevron-down text-[10px] transition-transform duration-200 pantry-acc-chevron ${chevronRot}"></i></span>`
|
||
: '';
|
||
|
||
const headerInner = `
|
||
<span class="${dot} w-2 h-2 rounded-full shrink-0" aria-hidden="true"></span>
|
||
<span class="flex-1 min-w-0">
|
||
<span class="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
|
||
<span class="text-[13px] font-bold text-gray-900 tracking-tight">${escapeHtml(title)}</span>
|
||
<span class="text-xs font-semibold tabular-nums text-gray-500">${count}</span>
|
||
</span>
|
||
<span class="block text-[11px] text-gray-500 mt-0.5">${escapeHtml(hint)}</span>
|
||
</span>
|
||
${chevron}`;
|
||
|
||
const header =
|
||
showToggle
|
||
? `<button type="button" class="${rowCls}" data-pantry-acc="${escapeHtml(sectionKey)}" aria-expanded="${isOpen}">${headerInner}</button>`
|
||
: `<div class="${rowCls}">${headerInner}</div>`;
|
||
|
||
return `
|
||
<section class="rounded-2xl bg-white border border-gray-200/90 shadow-sm ring-1 ${ring} overflow-hidden mb-3 last:mb-0" data-pantry-acc-wrap="${escapeHtml(sectionKey)}">
|
||
${header}
|
||
<div class="pantry-acc-panel px-2.5 pb-2.5 pt-0 ${isOpen ? '' : 'hidden'}" data-pantry-acc-panel="${escapeHtml(sectionKey)}">
|
||
${bodyInner}
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
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 `
|
||
<div class="${shell}" data-ingredient-id="${escapeHtml(id)}" data-pantry-variant="${variant}">
|
||
<div class="flex items-start justify-between gap-2 mb-2">
|
||
<div class="min-w-0">
|
||
<p class="text-sm font-semibold text-gray-900">${escapeHtml(def.name)}</p>
|
||
<p class="text-[10px] text-gray-500 mt-0.5">${escapeHtml(categoryLabel(def.category))} · magazyn: ${unit}</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<div class="flex items-center gap-1 ${variant === 'have' ? 'bg-emerald-100/60' : 'bg-gray-100'} rounded-lg p-0.5">
|
||
<button type="button" class="pantry-qty-minus w-8 h-8 rounded-md bg-white shadow-sm text-gray-700 hover:text-gray-900 flex items-center justify-center" aria-label="Mniej"><i class="fas fa-minus text-[10px]"></i></button>
|
||
<input type="number" min="0" step="1" inputmode="numeric" pattern="[0-9]*" data-pantry-qty data-pantry-step="${step}"
|
||
class="w-16 text-center text-sm font-semibold tabular-nums bg-transparent border-0 outline-none py-1"
|
||
value="${val}" placeholder="0" title="Wpisz liczbę całkowitą; +/− zmienia o ${step} ${unit}" />
|
||
<button type="button" class="pantry-qty-plus w-8 h-8 rounded-md bg-white shadow-sm text-gray-700 hover:text-gray-900 flex items-center justify-center" aria-label="Więcej"><i class="fas fa-plus text-[10px]"></i></button>
|
||
</div>
|
||
<button type="button" class="pantry-add-shop ml-auto flex items-center gap-1.5 px-3 py-2 rounded-lg bg-gray-900 text-white text-xs font-semibold hover:bg-black transition-colors">
|
||
<i class="fas fa-cart-plus text-[10px]"></i>
|
||
Na listę…
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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 = '<p class="text-sm text-gray-500 text-center py-10">Brak wyników — zmień wyszukiwanie lub filtr kategorii.</p>';
|
||
return;
|
||
}
|
||
|
||
const { have, catalogOnly } = splitHaveAndCatalog(ids, pantry);
|
||
|
||
const haveBody = have.length
|
||
? `<div class="space-y-2">${have.map((id) => pantryCardHtml(id, pantry, 'have')).join('')}</div>`
|
||
: '<p class="text-xs text-gray-500 text-center py-6 px-2">Żaden z widocznych produktów nie ma jeszcze zapasu — ustaw ilość w katalogu poniżej.</p>';
|
||
|
||
const catBody = catalogOnly.length
|
||
? `<div class="space-y-2">${catalogOnly.map((id) => pantryCardHtml(id, pantry, 'catalog')).join('')}</div>`
|
||
: '<p class="text-xs text-gray-500 text-center py-6 px-2">Wszystkie widoczne pozycje są na stanie.</p>';
|
||
|
||
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;
|
||
}
|