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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue