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:
parent
8f5457dff2
commit
b5735c270f
2 changed files with 161 additions and 9 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue