Redesign ingredient card
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m13s

This commit is contained in:
2026-04-10 15:04:36 +02:00
parent 2883dc858e
commit 86d47f126d
4 changed files with 234 additions and 67 deletions

View File

@@ -11,7 +11,7 @@
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Recipe App - Modular</title>
<link rel="manifest" href="./manifest.webmanifest?v=20260410-99">
<link rel="manifest" href="./manifest.webmanifest?v=20260410-106">
<link rel="icon" type="image/png" sizes="192x192" href="./icons/icon-192.png">
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -600,7 +600,7 @@
</div>
<script>
const APP_ASSET_VERSION = '20260410-99';
const APP_ASSET_VERSION = '20260410-106';
const APP_VERSION_STORAGE_KEY = 'recipe-app-asset-version';
const APP_VERSION_QUERY_KEY = 'appv';
@@ -634,7 +634,7 @@
})();
</script>
<script type="module">
const appVersion = window.__APP_ASSET_VERSION__ || '20260410-99';
const appVersion = window.__APP_ASSET_VERSION__ || '20260410-106';
const recoveryKey = `recipe-app-recovery-${appVersion}`;
function renderBootstrapError(message) {

View File

@@ -8,11 +8,15 @@ import {
} from '../data/catalog.js?v=8';
import {
addOrMergeShoppingLines,
KITCHEN_LIST_ID,
loadPantry,
loadShoppingState,
removeItemFromList,
setPantryQty,
setPantryProductQty,
getPantryTotal,
getPantryProducts,
updateKitchenItemAmount,
} from '../services/pantryShopping.js?v=2';
import { showAppToast } from './toast.js';
@@ -91,6 +95,18 @@ function sortProductsByStock(products, pantryItems) {
});
}
function formatPackAwareAmount(amount, pantryUnit, packSize, packLabel) {
const qty = Number(amount) || 0;
const unit = unitLabel(pantryUnit);
if (packSize && packSize > 0) {
const packs = qty / packSize;
if (qty > 0 && Number.isFinite(packs) && Math.abs(packs - Math.round(packs)) < 0.001) {
return `${formatQty(Math.round(packs))} x ${packLabel || `${formatQty(packSize)} ${unit}`}`;
}
}
return `${formatQty(qty)} ${unit}`;
}
export function getIngredientCardHTML({
idBase,
overlayClass = 'fixed inset-0 z-[70] hidden opacity-0 transition-opacity duration-200 flex items-center justify-center p-5',
@@ -140,12 +156,36 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
sourceNote: defaultSourceNote,
onProductChange: null,
onAfterChange: null,
stockEditorOpen: false,
stockDraftQty: null,
shopEditorOpen: false,
shopDraftQty: null,
closeTimer: null,
};
let bound = false;
const el = (suffix = '') => document.getElementById(suffix ? `${idBase}-${suffix}` : idBase);
function resetInlineEditors() {
state.stockEditorOpen = false;
state.stockDraftQty = null;
state.shopEditorOpen = false;
state.shopDraftQty = null;
}
function getCurrentShoppingItem(def) {
const shopping = loadShoppingState();
const kitchen = shopping.lists.find((list) => list.id === KITCHEN_LIST_ID && list.type === 'kitchen');
if (!kitchen || kitchen.type !== 'kitchen') return null;
const unit = unitLabel(def.pantryUnit);
return kitchen.items.find((item) => {
if (item.checked) return false;
return item.ingredientId === state.ingredientId
&& item.unit === unit
&& (item.productId || '') === (state.productId || '');
}) || null;
}
function renderHeader(def, product, pantry) {
const hasProducts = ingredientHasProducts(def.id);
const icon = CATEGORY_ICONS[def.category] || 'fa-jar';
@@ -213,11 +253,12 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
? 'dokładne dla produktu'
: hasProducts
? 'orientacyjnie dla składnika'
: 'bazowe wartości';
: '';
const nutritionMeta = hint ? `${unitScope}${hint}` : unitScope;
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Wartości odżywcze</p>
<p class="text-[10px] mb-1.5" style="color:#9b978f;">${esc(unitScope)}${esc(hint)}</p>
<p class="text-[10px] mb-1.5" style="color:#9b978f;">${esc(nutritionMeta)}</p>
<div class="grid grid-cols-4 gap-1.5">
<div class="rounded-xl px-2 py-1.5 text-center" style="background:#393937;">
<p class="text-[15px] font-bold tabular-nums leading-tight" style="color:#ddd6ca;">${nutrition.kcal}</p>
@@ -252,18 +293,17 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
if (hasProducts && !product) {
const stockedCount = getPantryProducts(state.ingredientId, pantry).filter((i) => i.qty > 0).length;
const summaryNutrition = nutritionForQty(def, totalQty);
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Zapas</p>
<div class="rounded-xl px-3 py-2.5" style="background:#393937;">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-[17px] font-bold tabular-nums" style="color:#6ee7b7;">${esc(formatQty(totalQty))} ${esc(unit)}</p>
<p class="text-[11px] mt-0.5" style="color:#9b978f;">${stockedCount} z ${getProductsForIngredient(state.ingredientId).length} produktów ma stan</p>
<div class="rounded-2xl border px-3 py-3" style="background:#393937; border-color:#444442;">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-[10px] font-semibold uppercase tracking-wide" style="color:#9b978f;">Stan łączny</p>
<p class="text-[16px] font-bold tabular-nums mt-1" style="color:#6ee7b7;">${esc(formatQty(totalQty))} ${esc(unit)}</p>
<p class="text-[11px] mt-1 leading-snug" style="color:#9b978f;">${stockedCount} z ${getProductsForIngredient(state.ingredientId).length} produktów ma zapas</p>
</div>
<span class="text-[10px] text-right max-w-[92px]" style="color:#9b978f;">Wybierz produkt niżej, aby zmienić stan</span>
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold shrink-0" style="background:#2f2f2d; color:#d7d2c8;">Wybierz produkt</span>
</div>
${summaryNutrition ? `<p class="text-[10px] mt-2 tabular-nums" style="color:#9b978f;">${esc(macroLine(summaryNutrition))}</p>` : ''}
</div>`;
return;
}
@@ -272,41 +312,87 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
? (getPantryProducts(state.ingredientId, pantry).find((i) => i.productId === state.productId)?.qty || 0)
: totalQty;
const step = product ? (product.packSize || pantryQtyStep(state.ingredientId)) : pantryQtyStep(state.ingredientId);
const stockNutrition = nutritionForQty(def, qty, product?.nutritionPer100g || def.nutritionPer100g);
const packSize = product?.packSize || def.purchasePack?.amount || 0;
const packLabel = product?.packLabel || def.purchasePack?.label || '';
const draftQty = state.stockEditorOpen
? Math.max(0, Number(state.stockDraftQty ?? qty) || 0)
: qty;
const actionLabel = state.stockEditorOpen ? 'Anuluj' : 'Zmień';
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Zapas</p>
<div class="flex items-center justify-center gap-3 rounded-xl px-3 py-2" style="background:#393937;">
<button type="button" class="ingredient-card-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95" style="background:#2f2f2d; color:#d7d2c8;" data-pid="${esc(state.productId || '_generic')}" data-step="${step}" data-dir="-1" aria-label="Zmniejsz stan">
<div class="rounded-2xl border px-3 py-3" style="background:#393937; border-color:#444442;">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-[16px] font-bold tabular-nums" style="color:#6ee7b7;">${esc(formatPackAwareAmount(qty, def.pantryUnit, packSize, packLabel))}</p>
</div>
<button type="button" class="ingredient-card-stock-toggle inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold shrink-0" style="background:${state.stockEditorOpen ? '#23221e' : '#2f2f2d'}; color:${state.stockEditorOpen ? '#f2efe8' : '#d7d2c8'};">
${esc(actionLabel)}
</button>
</div>
${state.stockEditorOpen ? `
<div class="mt-3 pt-3 border-t" style="border-color:#444442;">
<div class="flex items-center gap-2">
<button type="button" class="ingredient-card-stock-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-dir="-1" aria-label="Zmniejsz szkic zapasu">
<i class="fas fa-minus text-xs"></i>
</button>
<div class="flex-1 text-center">
<p class="text-[17px] font-bold tabular-nums" style="color:#6ee7b7;">${esc(formatQty(qty))} ${esc(unit)}</p>
<p class="text-[10px] mt-0.5" style="color:#9b978f;">Krok: ${esc(formatQty(step))} ${esc(unit)}</p>
</div>
<button type="button" class="ingredient-card-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95" style="background:#2f2f2d; color:#d7d2c8;" data-pid="${esc(state.productId || '_generic')}" data-step="${step}" data-dir="1" aria-label="Zwiększ stan">
<label class="flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2" style="background:#2f2f2d;">
<input type="number" min="0" step="${step}" value="${formatQty(draftQty)}" class="ingredient-card-stock-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:#ddd6ca; background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
<span class="text-[12px] font-medium shrink-0" style="color:#9b978f;">${esc(unit)}</span>
</label>
<button type="button" class="ingredient-card-stock-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-dir="1" aria-label="Zwiększ szkic zapasu">
<i class="fas fa-plus text-xs"></i>
</button>
</div>
${stockNutrition ? `<p class="text-[10px] mt-1.5 tabular-nums" style="color:#9b978f;">${esc(macroLine(stockNutrition))} na stanie</p>` : ''}`;
<div class="flex items-center justify-between gap-3 mt-3">
<button type="button" class="ingredient-card-stock-clear text-[11px] font-semibold" style="color:#9b978f;">Wyzeruj</button>
<button type="button" class="ingredient-card-stock-save inline-flex items-center rounded-full px-3 py-1.5 text-[11px] font-semibold" style="background:#ddd6ca; color:#2d2e2b;">Zapisz</button>
</div>
</div>` : ''}
</div>`;
wrap.querySelectorAll('.ingredient-card-stock-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const pid = btn.dataset.pid;
const stepVal = Number(btn.dataset.step) || 1;
const dir = Number(btn.dataset.dir) || 1;
const pantryState = loadPantry();
if (pid === '_generic') {
const current = getPantryTotal(state.ingredientId, pantryState);
setPantryQty(state.ingredientId, Math.max(0, current + stepVal * dir));
wrap.querySelector('.ingredient-card-stock-toggle')?.addEventListener('click', () => {
if (state.stockEditorOpen) {
state.stockEditorOpen = false;
state.stockDraftQty = null;
} else {
const items = getPantryProducts(state.ingredientId, pantryState);
const current = items.find((i) => i.productId === pid)?.qty || 0;
setPantryProductQty(state.ingredientId, pid, Math.max(0, current + stepVal * dir));
state.stockEditorOpen = true;
state.shopEditorOpen = false;
state.stockDraftQty = qty;
}
render();
state.onAfterChange?.();
});
wrap.querySelectorAll('.ingredient-card-stock-step').forEach((btn) => {
btn.addEventListener('click', () => {
const dir = Number(btn.dataset.dir) || 1;
const next = Math.max(0, Math.round(((Number(state.stockDraftQty ?? qty) || 0) + step * dir) * 100) / 100);
state.stockDraftQty = next;
render();
});
});
wrap.querySelector('.ingredient-card-stock-input')?.addEventListener('input', (event) => {
state.stockDraftQty = Math.max(0, Number(event.target.value) || 0);
});
wrap.querySelector('.ingredient-card-stock-clear')?.addEventListener('click', () => {
state.stockDraftQty = 0;
render();
});
wrap.querySelector('.ingredient-card-stock-save')?.addEventListener('click', () => {
const input = wrap.querySelector('.ingredient-card-stock-input');
const nextQty = Math.max(0, Math.round((Number(input?.value ?? state.stockDraftQty ?? qty) || 0) * 100) / 100);
if (state.productId) {
setPantryProductQty(state.ingredientId, state.productId, nextQty);
} else {
setPantryQty(state.ingredientId, nextQty);
}
state.stockEditorOpen = false;
state.stockDraftQty = null;
render();
state.onAfterChange?.();
});
}
@@ -359,6 +445,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const nextProductId = btn.dataset.productId || null;
state.productId = nextProductId;
state.selectedProductId = nextProductId;
resetInlineEditors();
if (nextProductId) state.onProductChange?.(nextProductId);
render();
state.onAfterChange?.();
@@ -377,28 +464,99 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const packSize = product?.packSize || def.purchasePack?.amount;
const packLabel = product?.packLabel || def.purchasePack?.label;
const usesPacks = Boolean(packSize && packSize > 0);
const btnLabel = usesPacks
? `Dodaj na listę (${packLabel || `${formatQty(packSize)} ${unitLabel(def.pantryUnit)}`})`
: 'Dodaj na listę';
const helperText = hasProducts && !product
? 'Doda składnik bez wskazanej marki. Jeśli chcesz konkretny produkt, wybierz go wyżej.'
: product
? 'Pozycja trafi na listę zakupów z dokładnym produktem.'
: `Szybki skrót do listy zakupów ${defaultSourceNote === 'Ze spiżarni' ? 'ze spiżarni' : 'z planera'}.`;
const defaultAmount = usesPacks ? packSize : pantryQtyStep(state.ingredientId);
const shoppingItem = getCurrentShoppingItem(def);
const hasShoppingItem = Boolean(shoppingItem);
const shoppingAmount = shoppingItem?.amount || 0;
const draftQty = state.shopEditorOpen
? Math.max(0, Number(state.shopDraftQty ?? (shoppingAmount || defaultAmount)) || 0)
: shoppingAmount;
const actionLabel = state.shopEditorOpen ? 'Anuluj' : 'Zmień';
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Lista zakupów</p>
<p class="text-[10px] mb-1.5" style="color:#9b978f;">${esc(helperText)}</p>
<button type="button" class="ingredient-card-add-list w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-[13px] font-semibold transition-colors active:scale-[0.98]" style="background:#ddd6ca; color:#2d2e2b;">
<i class="fas fa-cart-plus text-[11px]"></i>${esc(btnLabel)}
</button>`;
<div class="rounded-2xl border px-3 py-3" style="background:#393937; border-color:#444442;">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-[16px] font-bold tabular-nums" style="color:${hasShoppingItem ? '#ddd6ca' : '#9b978f'};">${esc(hasShoppingItem ? formatPackAwareAmount(shoppingAmount, def.pantryUnit, packSize, packLabel) : 'Brak na liscie')}</p>
</div>
<button type="button" class="ingredient-card-shop-toggle inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold shrink-0" style="background:${state.shopEditorOpen ? '#23221e' : '#2f2f2d'}; color:${state.shopEditorOpen ? '#f2efe8' : '#d7d2c8'};">
${esc(actionLabel)}
</button>
</div>
${state.shopEditorOpen ? `
<div class="mt-3 pt-3 border-t" style="border-color:#444442;">
<div class="flex items-center gap-2">
<button type="button" class="ingredient-card-shop-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-dir="-1" aria-label="Zmniejsz ilość na liście">
<i class="fas fa-minus text-xs"></i>
</button>
<label class="flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2" style="background:#2f2f2d;">
<input type="number" min="0" step="${defaultAmount}" value="${formatQty(draftQty)}" class="ingredient-card-shop-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:#ddd6ca; background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
<span class="text-[12px] font-medium shrink-0" style="color:#9b978f;">${esc(unitLabel(def.pantryUnit))}</span>
</label>
<button type="button" class="ingredient-card-shop-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-dir="1" aria-label="Zwiększ ilość na liście">
<i class="fas fa-plus text-xs"></i>
</button>
</div>
<div class="flex items-center justify-between gap-3 mt-3">
${hasShoppingItem
? '<button type="button" class="ingredient-card-shop-remove text-[11px] font-semibold" style="color:#9b978f;">Usuń z listy</button>'
: '<span></span>'}
<button type="button" class="ingredient-card-shop-save inline-flex items-center rounded-full px-3 py-1.5 text-[11px] font-semibold" style="background:#ddd6ca; color:#2d2e2b;">Zapisz</button>
</div>
</div>` : ''}
</div>`;
wrap.querySelector('.ingredient-card-add-list')?.addEventListener('click', () => {
const amount = usesPacks ? packSize : pantryQtyStep(state.ingredientId);
wrap.querySelector('.ingredient-card-shop-toggle')?.addEventListener('click', () => {
if (state.shopEditorOpen) {
state.shopEditorOpen = false;
state.shopDraftQty = null;
} else {
state.shopEditorOpen = true;
state.stockEditorOpen = false;
state.shopDraftQty = shoppingAmount || defaultAmount;
}
render();
});
wrap.querySelectorAll('.ingredient-card-shop-step').forEach((btn) => {
btn.addEventListener('click', () => {
const dir = Number(btn.dataset.dir) || 1;
const next = Math.max(0, Math.round(((Number(state.shopDraftQty ?? (shoppingAmount || defaultAmount)) || 0) + defaultAmount * dir) * 100) / 100);
state.shopDraftQty = next;
render();
});
});
wrap.querySelector('.ingredient-card-shop-input')?.addEventListener('input', (event) => {
state.shopDraftQty = Math.max(0, Number(event.target.value) || 0);
});
wrap.querySelector('.ingredient-card-shop-remove')?.addEventListener('click', () => {
if (!shoppingItem) return;
removeItemFromList(KITCHEN_LIST_ID, shoppingItem.id);
state.shopEditorOpen = false;
state.shopDraftQty = null;
render();
state.onAfterChange?.();
window.refreshShopping?.();
showAppToast(`Usunieto ${product?.name || def.name} z listy.`);
});
wrap.querySelector('.ingredient-card-shop-save')?.addEventListener('click', () => {
const input = wrap.querySelector('.ingredient-card-shop-input');
const nextAmount = Math.max(0, Math.round((Number(input?.value ?? state.shopDraftQty ?? defaultAmount) || 0) * 100) / 100);
let toastText = null;
if (shoppingItem) {
updateKitchenItemAmount(KITCHEN_LIST_ID, shoppingItem.id, nextAmount);
toastText = nextAmount > 0
? `Zaktualizowano ${product?.name || def.name}.`
: `Usunieto ${product?.name || def.name} z listy.`;
} else if (nextAmount > 0) {
const note = usesPacks ? (packLabel || `${formatQty(packSize)} ${unitLabel(def.pantryUnit)}`) : undefined;
const line = {
ingredientId: state.ingredientId,
amount,
amount: nextAmount,
unit: unitLabel(def.pantryUnit),
name: product?.name || def.name,
category: def.category,
@@ -406,8 +564,14 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
};
if (state.productId) line.productId = state.productId;
addOrMergeShoppingLines([line]);
showAppToast(`Dodano ${product?.name || def.name} na listę.`);
toastText = `Dodano ${product?.name || def.name}.`;
}
state.shopEditorOpen = false;
state.shopDraftQty = null;
render();
state.onAfterChange?.();
window.refreshShopping?.();
if (toastText) showAppToast(toastText);
});
}
@@ -444,6 +608,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
state.sourceNote = sourceNote;
state.onProductChange = onProductChange;
state.onAfterChange = onAfterChange;
resetInlineEditors();
render();
clearTimeout(state.closeTimer);
@@ -472,6 +637,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
state.onProductChange = null;
state.onAfterChange = null;
state.sourceNote = defaultSourceNote;
resetInlineEditors();
}
function bind() {
@@ -480,6 +646,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
el('close')?.addEventListener('click', close);
el('back')?.addEventListener('click', () => {
state.productId = null;
resetInlineEditors();
render();
});
el('overlay')?.addEventListener('click', (event) => {

View File

@@ -26,7 +26,7 @@ import {
renderCalendarGrid,
syncCalendarTodayButton,
} from './mealCalendar.js?v=1';
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260410-99';
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260410-106';
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

View File

@@ -9,7 +9,7 @@ import {
loadPantry,
getPantryTotal,
} from '../services/pantryShopping.js?v=2';
import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260410-99';
import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260410-106';
/* ── helpers ── */