From b5735c270fa584d80e848366689a49a437a9e4a8 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 29 Jul 2026 18:33:51 +0200 Subject: [PATCH] tablet: +/- buttons on cart lines, long-press drink for 1-5 picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/styles.css | 55 ++++++++++++++++++ client/src/tablet/Sale.tsx | 115 ++++++++++++++++++++++++++++++++++--- 2 files changed, 161 insertions(+), 9 deletions(-) diff --git a/client/src/styles.css b/client/src/styles.css index 0971da3..21fb3f6 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -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; diff --git a/client/src/tablet/Sale.tsx b/client/src/tablet/Sale.tsx index 258483f..9177c14 100644 --- a/client/src/tablet/Sale.tsx +++ b/client/src/tablet/Sale.tsx @@ -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([]); const [pfandReturns, setPfandReturns] = useState(0); const [submitting, setSubmitting] = useState(false); + const [pickerDrink, setPickerDrink] = useState(null); + + const longPressTimer = useRef(null); + const longPressFired = useRef(false); const drinkById = useMemo(() => { - const m = new Map(); + const m = new Map(); 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) {
{drinks.map(d => ( -
+ {pickerDrink && ( +
setPickerDrink(null)}> +
e.stopPropagation()}> +
{pickerDrink.name}
+
+ {PICKER_COUNTS.map(n => ( + + ))} +
+ +
+
+ )} +
{isEmpty &&
Keine Einträge
} @@ -111,7 +192,15 @@ export function Sale({ config, onChangeBar }: Props) { if (!d) return null; return (
- {l.qty}× {d.name} +
+ + {l.qty}× {d.name} + +
{formatCents(d.price_cents * l.qty)}
); @@ -124,7 +213,15 @@ export function Sale({ config, onChangeBar }: Props) { )} {pfandReturns > 0 && (
- {pfandReturns}× Pfand zurück +
+ + {pfandReturns}× Pfand zurück + +
{formatCents(-bar.pfand_cents * pfandReturns)}
)}