tablet: +/- buttons on cart lines, long-press drink for 1-5 picker

Cart entries (drink lines and Pfand-zurück) now have +/- buttons next
to the quantity instead of only accumulating via repeated taps;
decrementing to 0 removes the line. Long-pressing a drink tile opens
a small overlay with buttons 1-5 to add that many at once — a plain
tap still adds one. The long-press timer is cancelled on pointerup/
leave/cancel, and the click that follows a fired long-press is
swallowed so it doesn't also add a plain 1x.
This commit is contained in:
iris 2026-07-29 18:33:51 +02:00
commit b5735c270f
2 changed files with 161 additions and 9 deletions

View file

@ -155,6 +155,46 @@ button.danger { background: #5a2a2a; border-color: #7a3a3a; color: #fff; }
color: #fff; color: #fff;
} }
.picker-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.picker {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
width: min(320px, 86vw);
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14px;
}
.picker-title {
font-size: 22px;
font-weight: 800;
text-align: center;
}
.picker-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.picker-count {
height: 64px;
font-size: 26px;
font-weight: 800;
}
.picker-cancel {
background: transparent;
border-color: var(--border-soft);
}
.cart { .cart {
flex: 0 0 38vh; flex: 0 0 38vh;
background: var(--surface); background: var(--surface);
@ -176,6 +216,7 @@ button.danger { background: #5a2a2a; border-color: #7a3a3a; color: #fff; }
.cart-line { .cart-line {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
padding: 3px 0; padding: 3px 0;
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
@ -183,6 +224,20 @@ button.danger { background: #5a2a2a; border-color: #7a3a3a; color: #fff; }
.cart-line.pfand { color: var(--pfand); } .cart-line.pfand { color: var(--pfand); }
.cart-line.return { color: var(--danger); } .cart-line.return { color: var(--danger); }
.cart-line.muted { opacity: 0.5; } .cart-line.muted { opacity: 0.5; }
.cart-line-label {
display: flex;
align-items: center;
gap: 8px;
}
.qty-btn {
flex: 0 0 auto;
width: 28px;
height: 28px;
padding: 0;
line-height: 1;
font-size: 18px;
border-radius: 6px;
}
.cart-total { .cart-total {
font-size: 56px; font-size: 56px;
font-weight: 800; font-weight: 800;

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from 'preact/hooks'; import { useMemo, useRef, useState } from 'preact/hooks';
import type { BarConfig, CartItem } from '@wutzcalc/shared'; import type { BarConfig, CartItem, Drink } from '@wutzcalc/shared';
import { api, formatCents, uuid } from '../api'; import { api, formatCents, uuid } from '../api';
interface Props { interface Props {
@ -9,14 +9,21 @@ interface Props {
type Line = { drink_id: number; qty: number }; 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) { export function Sale({ config, onChangeBar }: Props) {
const { bar, drinks } = config; const { bar, drinks } = config;
const [lines, setLines] = useState<Line[]>([]); const [lines, setLines] = useState<Line[]>([]);
const [pfandReturns, setPfandReturns] = useState(0); const [pfandReturns, setPfandReturns] = useState(0);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [pickerDrink, setPickerDrink] = useState<Drink | null>(null);
const longPressTimer = useRef<number | null>(null);
const longPressFired = useRef(false);
const drinkById = useMemo(() => { const drinkById = useMemo(() => {
const m = new Map<number, typeof drinks[number]>(); const m = new Map<number, Drink>();
drinks.forEach(d => m.set(d.id, d)); drinks.forEach(d => m.set(d.id, d));
return m; return m;
}, [drinks]); }, [drinks]);
@ -30,15 +37,27 @@ export function Sale({ config, onChangeBar }: Props) {
return drinksSum - bar.pfand_cents * pfandReturns; return drinksSum - bar.pfand_cents * pfandReturns;
}, [lines, drinkById, bar.pfand_cents, pfandReturns]); }, [lines, drinkById, bar.pfand_cents, pfandReturns]);
function addDrink(drinkId: number) { function addDrink(drinkId: number, qty = 1) {
setLines(prev => { setLines(prev => {
const i = prev.findIndex(l => l.drink_id === drinkId); const i = prev.findIndex(l => l.drink_id === drinkId);
if (i >= 0) { if (i >= 0) {
const copy = prev.slice(); const copy = prev.slice();
copy[i] = { ...copy[i]!, qty: copy[i]!.qty + 1 }; copy[i] = { ...copy[i]!, qty: copy[i]!.qty + qty };
return copy; return copy;
} }
return [...prev, { drink_id: drinkId, qty: 1 }]; 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;
}); });
} }
@ -46,11 +65,46 @@ export function Sale({ config, onChangeBar }: Props) {
setPfandReturns(n => n + 1); setPfandReturns(n => n + 1);
} }
function incPfandReturns(delta: number) {
setPfandReturns(n => Math.max(0, n + delta));
}
function clear() { function clear() {
setLines([]); setLines([]);
setPfandReturns(0); setPfandReturns(0);
} }
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 pfandCount = useMemo(() => lines.reduce((sum, l) => sum + l.qty, 0), [lines]);
const isEmpty = lines.length === 0 && pfandReturns === 0; const isEmpty = lines.length === 0 && pfandReturns === 0;
@ -87,7 +141,16 @@ export function Sale({ config, onChangeBar }: Props) {
<div class="grid"> <div class="grid">
{drinks.map(d => ( {drinks.map(d => (
<button key={d.id} class="drink" onClick={() => addDrink(d.id)}> <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="name">{d.name}</div>
<div class="price">{formatCents(d.price_cents)}</div> <div class="price">{formatCents(d.price_cents)}</div>
{bar.pfand_cents > 0 && ( {bar.pfand_cents > 0 && (
@ -103,6 +166,24 @@ export function Sale({ config, onChangeBar }: Props) {
)} )}
</div> </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">
<div class="cart-items"> <div class="cart-items">
{isEmpty && <div class="cart-line muted">Keine Einträge</div>} {isEmpty && <div class="cart-line muted">Keine Einträge</div>}
@ -111,7 +192,15 @@ export function Sale({ config, onChangeBar }: Props) {
if (!d) return null; if (!d) return null;
return ( return (
<div key={i} class="cart-line"> <div key={i} class="cart-line">
<span>{l.qty}× {d.name}</span> <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> <span>{formatCents(d.price_cents * l.qty)}</span>
</div> </div>
); );
@ -124,7 +213,15 @@ export function Sale({ config, onChangeBar }: Props) {
)} )}
{pfandReturns > 0 && ( {pfandReturns > 0 && (
<div class="cart-line return"> <div class="cart-line return">
<span>{pfandReturns}× Pfand zurück</span> <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> <span>{formatCents(-bar.pfand_cents * pfandReturns)}</span>
</div> </div>
)} )}