wutzcalc/client/src/tablet/Sale.tsx
iris cb316d16d1 tablet: fix cart scroll — flex-end + overflow-y:auto doesn't scroll in WebKit
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.
2026-07-30 12:17:15 +02:00

260 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}