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;
}
.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 {
flex: 0 0 38vh;
background: var(--surface);
@ -176,6 +216,7 @@ button.danger { background: #5a2a2a; border-color: #7a3a3a; color: #fff; }
.cart-line {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 0;
font-size: 20px;
font-weight: 600;
@ -183,6 +224,20 @@ button.danger { background: #5a2a2a; border-color: #7a3a3a; color: #fff; }
.cart-line.pfand { color: var(--pfand); }
.cart-line.return { color: var(--danger); }
.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 {
font-size: 56px;
font-weight: 800;

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from 'preact/hooks';
import type { BarConfig, CartItem } from '@wutzcalc/shared';
import { useMemo, useRef, useState } from 'preact/hooks';
import type { BarConfig, CartItem, Drink } from '@wutzcalc/shared';
import { api, formatCents, uuid } from '../api';
interface Props {
@ -9,14 +9,21 @@ interface Props {
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);
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));
return m;
}, [drinks]);
@ -30,15 +37,27 @@ export function Sale({ config, onChangeBar }: Props) {
return drinksSum - bar.pfand_cents * pfandReturns;
}, [lines, drinkById, bar.pfand_cents, pfandReturns]);
function addDrink(drinkId: number) {
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 + 1 };
copy[i] = { ...copy[i]!, qty: copy[i]!.qty + qty };
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);
}
function incPfandReturns(delta: number) {
setPfandReturns(n => Math.max(0, n + delta));
}
function clear() {
setLines([]);
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 isEmpty = lines.length === 0 && pfandReturns === 0;
@ -87,7 +141,16 @@ export function Sale({ config, onChangeBar }: Props) {
<div class="grid">
{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="price">{formatCents(d.price_cents)}</div>
{bar.pfand_cents > 0 && (
@ -103,6 +166,24 @@ export function Sale({ config, onChangeBar }: Props) {
)}
</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">
{isEmpty && <div class="cart-line muted">Keine Einträge</div>}
@ -111,7 +192,15 @@ export function Sale({ config, onChangeBar }: Props) {
if (!d) return null;
return (
<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>
</div>
);
@ -124,7 +213,15 @@ export function Sale({ config, onChangeBar }: Props) {
)}
{pfandReturns > 0 && (
<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>
</div>
)}