Follow-up to the #39 fix in d274275, which mara reports still doesn't
scroll in practice.
The bug: overflow-y: auto and justify-content: flex-end on the *same* flex
column is a known WebKit/Safari interop gap — the flex-end-justified
overflow gets clipped at the container bounds instead of becoming a
scrollable region, so nothing actually changed from the user's side despite
the CSS being 'correct' per spec. This app targets iOS Safari 12+
(vite.config.ts legacy targets), squarely in the affected range.
Fix: split the concerns onto two elements. .cart-items is now a plain
overflow-y: auto scroll container with no alignment property. The new
inner .cart-items-inner wrapper carries display:flex/flex-direction:column/
justify-content:flex-end, with min-height: 100% (a floor, not a fixed
height) so it still bottom-anchors a short list but grows past 100% and
scrolls normally in the outer container once content overflows it.
Build clean.
260 lines
8.4 KiB
TypeScript
260 lines
8.4 KiB
TypeScript
import { useMemo, useRef, useState } from 'preact/hooks';
|
||
import type { BarConfig, CartItem, Drink } from '@wutzcalc/shared';
|
||
import { api, formatCents, uuid } from '../api';
|
||
|
||
interface Props {
|
||
config: BarConfig;
|
||
onChangeBar: () => void;
|
||
}
|
||
|
||
type Line = { drink_id: number; qty: number };
|
||
|
||
const LONG_PRESS_MS = 450;
|
||
const PICKER_COUNTS = [1, 2, 3, 4, 5];
|
||
|
||
export function Sale({ config, onChangeBar }: Props) {
|
||
const { bar, drinks } = config;
|
||
const [lines, setLines] = useState<Line[]>([]);
|
||
const [pfandReturns, setPfandReturns] = useState(0);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [pickerDrink, setPickerDrink] = useState<Drink | null>(null);
|
||
|
||
const longPressTimer = useRef<number | null>(null);
|
||
const longPressFired = useRef(false);
|
||
|
||
// One idempotency key per pending cart, reused across retries of the same
|
||
// logical submission (e.g. after a timeout/dropped-connection error) so
|
||
// the server's client_uuid dedup can actually recognise a resend. Reset
|
||
// whenever the cart is cleared (submitted successfully or cancelled), so
|
||
// the *next* cart gets its own fresh id.
|
||
const cartUuidRef = useRef<string | null>(null);
|
||
function cartUuid() {
|
||
if (!cartUuidRef.current) cartUuidRef.current = uuid();
|
||
return cartUuidRef.current;
|
||
}
|
||
|
||
const drinkById = useMemo(() => {
|
||
const m = new Map<number, Drink>();
|
||
drinks.forEach(d => m.set(d.id, d));
|
||
return m;
|
||
}, [drinks]);
|
||
|
||
const total = useMemo(() => {
|
||
const drinksSum = lines.reduce((sum, l) => {
|
||
const d = drinkById.get(l.drink_id);
|
||
if (!d) return sum;
|
||
return sum + (d.price_cents + bar.pfand_cents) * l.qty;
|
||
}, 0);
|
||
return drinksSum - bar.pfand_cents * pfandReturns;
|
||
}, [lines, drinkById, bar.pfand_cents, pfandReturns]);
|
||
|
||
function addDrink(drinkId: number, qty = 1) {
|
||
setLines(prev => {
|
||
const i = prev.findIndex(l => l.drink_id === drinkId);
|
||
if (i >= 0) {
|
||
const copy = prev.slice();
|
||
copy[i] = { ...copy[i]!, qty: copy[i]!.qty + qty };
|
||
return copy;
|
||
}
|
||
return [...prev, { drink_id: drinkId, qty }];
|
||
});
|
||
}
|
||
|
||
function incLine(drinkId: number, delta: number) {
|
||
setLines(prev => {
|
||
const i = prev.findIndex(l => l.drink_id === drinkId);
|
||
if (i < 0) return prev;
|
||
const nextQty = prev[i]!.qty + delta;
|
||
if (nextQty <= 0) return prev.filter(l => l.drink_id !== drinkId);
|
||
const copy = prev.slice();
|
||
copy[i] = { ...copy[i]!, qty: nextQty };
|
||
return copy;
|
||
});
|
||
}
|
||
|
||
function addPfandReturn() {
|
||
setPfandReturns(n => n + 1);
|
||
}
|
||
|
||
function incPfandReturns(delta: number) {
|
||
setPfandReturns(n => Math.max(0, n + delta));
|
||
}
|
||
|
||
function clear() {
|
||
setLines([]);
|
||
setPfandReturns(0);
|
||
cartUuidRef.current = null;
|
||
}
|
||
|
||
function startLongPress(d: Drink) {
|
||
longPressFired.current = false;
|
||
cancelLongPress();
|
||
longPressTimer.current = window.setTimeout(() => {
|
||
longPressFired.current = true;
|
||
setPickerDrink(d);
|
||
}, LONG_PRESS_MS);
|
||
}
|
||
|
||
function cancelLongPress() {
|
||
if (longPressTimer.current != null) {
|
||
clearTimeout(longPressTimer.current);
|
||
longPressTimer.current = null;
|
||
}
|
||
}
|
||
|
||
function handleDrinkClick(d: Drink) {
|
||
// a long press already opened the count picker for this tap — swallow
|
||
// the click that pointerup/click also fires, don't add a plain 1×.
|
||
if (longPressFired.current) {
|
||
longPressFired.current = false;
|
||
return;
|
||
}
|
||
addDrink(d.id);
|
||
}
|
||
|
||
function pickCount(n: number) {
|
||
if (pickerDrink) addDrink(pickerDrink.id, n);
|
||
setPickerDrink(null);
|
||
}
|
||
|
||
const pfandCount = useMemo(() => lines.reduce((sum, l) => sum + l.qty, 0), [lines]);
|
||
|
||
const isEmpty = lines.length === 0 && pfandReturns === 0;
|
||
|
||
async function confirm(crew: boolean) {
|
||
if (isEmpty || submitting) return;
|
||
setSubmitting(true);
|
||
const items: CartItem[] = lines.map(l => ({ drink_id: l.drink_id, qty: l.qty }));
|
||
try {
|
||
await api.createTransaction({
|
||
client_uuid: cartUuid(),
|
||
bar_id: bar.id,
|
||
crew,
|
||
items,
|
||
pfand_returns: pfandReturns,
|
||
});
|
||
clear();
|
||
} catch (e) {
|
||
alert(`Fehler beim Speichern: ${e}`);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div class="tablet">
|
||
<div class="topbar">
|
||
<div class="brand">
|
||
<img class="logo" src="/favicon.svg" alt="" width="28" height="28" />
|
||
<span class="bar-name">{bar.name}</span>
|
||
</div>
|
||
<button class="change" onClick={onChangeBar}>Bar wechseln</button>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
{drinks.map(d => (
|
||
<button
|
||
key={d.id}
|
||
class="drink"
|
||
onPointerDown={() => startLongPress(d)}
|
||
onPointerUp={cancelLongPress}
|
||
onPointerLeave={cancelLongPress}
|
||
onPointerCancel={cancelLongPress}
|
||
onContextMenu={e => e.preventDefault()}
|
||
onClick={() => handleDrinkClick(d)}
|
||
>
|
||
<div class="name">{d.name}</div>
|
||
<div class="price">{formatCents(d.price_cents)}</div>
|
||
{bar.pfand_cents > 0 && (
|
||
<div class="pfand">+ {formatCents(bar.pfand_cents)} Pfand</div>
|
||
)}
|
||
</button>
|
||
))}
|
||
{bar.pfand_cents > 0 && (
|
||
<button class="drink pfand-return" onClick={addPfandReturn}>
|
||
<div class="name">Pfand zurück</div>
|
||
<div class="price">−{formatCents(bar.pfand_cents)}</div>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{pickerDrink && (
|
||
<div class="picker-backdrop" onClick={() => setPickerDrink(null)}>
|
||
<div class="picker" onClick={e => e.stopPropagation()}>
|
||
<div class="picker-title">{pickerDrink.name}</div>
|
||
<div class="picker-grid">
|
||
{PICKER_COUNTS.map(n => (
|
||
<button key={n} class="picker-count" onClick={() => pickCount(n)}>
|
||
{n}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button class="picker-cancel" onClick={() => setPickerDrink(null)}>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div class="cart">
|
||
<div class="cart-items">
|
||
<div class="cart-items-inner">
|
||
{isEmpty && <div class="cart-line muted">Keine Einträge</div>}
|
||
{lines.map((l, i) => {
|
||
const d = drinkById.get(l.drink_id);
|
||
if (!d) return null;
|
||
return (
|
||
<div key={i} class="cart-line">
|
||
<div class="cart-line-label">
|
||
<button class="qty-btn" onClick={() => incLine(l.drink_id, -1)} aria-label="weniger">
|
||
−
|
||
</button>
|
||
<span>{l.qty}× {d.name}</span>
|
||
<button class="qty-btn" onClick={() => incLine(l.drink_id, 1)} aria-label="mehr">
|
||
+
|
||
</button>
|
||
</div>
|
||
<span>{formatCents(d.price_cents * l.qty)}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
{bar.pfand_cents > 0 && pfandCount > 0 && (
|
||
<div class="cart-line pfand">
|
||
<span>{pfandCount}× Pfand</span>
|
||
<span>{formatCents(bar.pfand_cents * pfandCount)}</span>
|
||
</div>
|
||
)}
|
||
{pfandReturns > 0 && (
|
||
<div class="cart-line return">
|
||
<div class="cart-line-label">
|
||
<button class="qty-btn" onClick={() => incPfandReturns(-1)} aria-label="weniger">
|
||
−
|
||
</button>
|
||
<span>{pfandReturns}× Pfand zurück</span>
|
||
<button class="qty-btn" onClick={() => incPfandReturns(1)} aria-label="mehr">
|
||
+
|
||
</button>
|
||
</div>
|
||
<span>{formatCents(-bar.pfand_cents * pfandReturns)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div class={`cart-total ${total < 0 ? 'negative' : ''}`}>
|
||
{formatCents(total)}
|
||
</div>
|
||
<div class="actions">
|
||
<button class="cancel" onClick={clear} disabled={isEmpty || submitting}>
|
||
Abbrechen
|
||
</button>
|
||
<button class="crew" onClick={() => confirm(true)} disabled={isEmpty || submitting}>
|
||
Crew
|
||
</button>
|
||
<button class="confirm" onClick={() => confirm(false)} disabled={isEmpty || submitting}>
|
||
Bestätigen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|