Frontend overhaul: manager dashboard restructure, waiter PWA rework, new order drawer and components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
920
CLAUDE_DESIGN/order-drawer.jsx
Normal file
920
CLAUDE_DESIGN/order-drawer.jsx
Normal file
@@ -0,0 +1,920 @@
|
||||
// Order drawer — add/customize a product before sending to the kitchen.
|
||||
// Mobile-portrait, ~90% height bottom sheet with horizontal tabs.
|
||||
|
||||
const { DIAVOLA } = window;
|
||||
|
||||
// --- utils -----------------------------------------------------------------
|
||||
const fmt = (n) => {
|
||||
const sign = n < 0 ? '−' : '';
|
||||
return sign + '€' + Math.abs(n).toFixed(2);
|
||||
};
|
||||
const fmtSigned = (n) => (n >= 0 ? '+' : '−') + '€' + Math.abs(n).toFixed(2);
|
||||
|
||||
// --- stepper (big touch) ---------------------------------------------------
|
||||
function Stepper({ value, onChange, min = 0, max = 99, size = 'md' }) {
|
||||
const sizes = {
|
||||
md: { btn: 40, font: 18, w: 108 },
|
||||
lg: { btn: 48, font: 22, w: 132 },
|
||||
};
|
||||
const s = sizes[size];
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
height: s.btn, width: s.w,
|
||||
borderRadius: s.btn / 2,
|
||||
background: 'white',
|
||||
border: '1px solid var(--ink-200)',
|
||||
overflow: 'hidden',
|
||||
}} onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => onChange(Math.max(min, value - 1))}
|
||||
disabled={value <= min}
|
||||
style={{
|
||||
width: s.btn, height: s.btn,
|
||||
border: 'none', background: 'transparent',
|
||||
fontSize: s.font, fontWeight: 500,
|
||||
color: value <= min ? 'var(--ink-300)' : 'var(--ink-900)',
|
||||
cursor: value <= min ? 'default' : 'pointer',
|
||||
}}>−</button>
|
||||
<div style={{
|
||||
flex: 1, textAlign: 'center',
|
||||
fontSize: s.font - 2, fontWeight: 600,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
color: 'var(--ink-900)',
|
||||
}}>{value}</div>
|
||||
<button
|
||||
onClick={() => onChange(Math.min(max, value + 1))}
|
||||
disabled={value >= max}
|
||||
style={{
|
||||
width: s.btn, height: s.btn,
|
||||
border: 'none', background: 'transparent',
|
||||
fontSize: s.font, fontWeight: 500,
|
||||
color: value >= max ? 'var(--ink-300)' : 'var(--ink-900)',
|
||||
cursor: value >= max ? 'default' : 'pointer',
|
||||
}}>+</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- checkmark icon --------------------------------------------------------
|
||||
function Check({ size = 18 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 12.5L10 17.5L19 7.5" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function Chevron({ open, size = 16 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
style={{ transform: `rotate(${open ? 180 : 0}deg)`, transition: 'transform 180ms ease' }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Row primitive (shared look across tabs) -------------------------------
|
||||
function Row({ selected, onClick, left, right, children }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
background: selected ? 'var(--brand-50)' : 'white',
|
||||
border: '1px solid ' + (selected ? 'var(--brand-200)' : 'var(--ink-100)'),
|
||||
borderRadius: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'background 120ms ease, border-color 120ms ease',
|
||||
minHeight: 60,
|
||||
}}
|
||||
>
|
||||
{left && <div style={{ flexShrink: 0 }}>{left}</div>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
||||
{right && <div style={{ flexShrink: 0 }}>{right}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Checkbox circle (selected / not) --------------------------------------
|
||||
function CheckCircle({ selected, size = 26 }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid ' + (selected ? 'var(--brand-500)' : 'var(--ink-300)'),
|
||||
background: selected ? 'var(--brand-500)' : 'white',
|
||||
color: 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 120ms ease',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{selected && <Check size={size * 0.65} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Radio dot -------------------------------------------------------------
|
||||
function RadioDot({ selected, size = 22 }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid ' + (selected ? 'var(--brand-500)' : 'var(--ink-300)'),
|
||||
background: 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{selected && (
|
||||
<div style={{
|
||||
width: size - 10, height: size - 10,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--brand-500)',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// QUICK OPTIONS TAB
|
||||
// ===========================================================================
|
||||
function QuickOptionsTab({ options, state, setState }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
|
||||
{options.map(opt => {
|
||||
const qty = state[opt.id] || 0;
|
||||
const selected = qty > 0;
|
||||
return (
|
||||
<Row
|
||||
key={opt.id}
|
||||
selected={selected}
|
||||
onClick={opt.multi ? undefined : () => setState({ ...state, [opt.id]: selected ? 0 : 1 })}
|
||||
left={!opt.multi && <CheckCircle selected={selected} />}
|
||||
right={
|
||||
opt.multi ? (
|
||||
selected ? (
|
||||
<Stepper value={qty} onChange={(v) => setState({ ...state, [opt.id]: v })} />
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setState({ ...state, [opt.id]: 1 }); }}
|
||||
style={{
|
||||
height: 40, padding: '0 18px',
|
||||
borderRadius: 20,
|
||||
background: 'white',
|
||||
border: '1.5px solid var(--ink-200)',
|
||||
color: 'var(--ink-900)',
|
||||
fontSize: 15, fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>Add</button>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: 'var(--ink-900)' }}>{opt.label}</div>
|
||||
{opt.price > 0 && (
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)', marginTop: 2 }}>
|
||||
+€{opt.price.toFixed(2)} {opt.multi ? 'each' : ''}
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// EXTRAS TAB — each row expands inline to pick a sub-option
|
||||
// ===========================================================================
|
||||
function ExtrasTab({ extras, state, setState, expandedId, setExpandedId }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
|
||||
{extras.map(ex => {
|
||||
const selection = state[ex.id]; // { qty, subId } | undefined
|
||||
const selected = !!selection;
|
||||
const open = expandedId === ex.id;
|
||||
const subLabel = selection
|
||||
? ex.subOptions.find(s => s.id === selection.subId)?.label
|
||||
: null;
|
||||
|
||||
const toggle = () => {
|
||||
if (selected) {
|
||||
setState({ ...state, [ex.id]: undefined });
|
||||
if (open) setExpandedId(null);
|
||||
} else {
|
||||
// default to first sub-option, auto-expand so user can change it
|
||||
setState({ ...state, [ex.id]: { qty: 1, subId: ex.subOptions[0].id } });
|
||||
setExpandedId(ex.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={ex.id}>
|
||||
<Row
|
||||
selected={selected}
|
||||
onClick={toggle}
|
||||
left={<CheckCircle selected={selected} />}
|
||||
right={
|
||||
selected ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }} onClick={(e) => e.stopPropagation()}>
|
||||
{ex.multi && (
|
||||
<Stepper
|
||||
value={selection.qty}
|
||||
onChange={(v) => setState({ ...state, [ex.id]: v === 0 ? undefined : { ...selection, qty: v } })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setExpandedId(open ? null : ex.id); }}
|
||||
style={{
|
||||
height: 40, width: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'white',
|
||||
border: '1px solid var(--ink-200)',
|
||||
color: 'var(--ink-700)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Chevron open={open} />
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: 'var(--ink-900)' }}>{ex.label}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)', marginTop: 2 }}>
|
||||
{ex.price > 0 ? `+€${ex.price.toFixed(2)}${ex.multi ? ' each' : ''}` : 'Included'}
|
||||
{subLabel && <span style={{ color: 'var(--brand-700)', fontWeight: 600 }}> · {subLabel}</span>}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Inline sub-option picker */}
|
||||
{selected && open && (
|
||||
<div style={{
|
||||
margin: '6px 0 2px 16px',
|
||||
paddingLeft: 14,
|
||||
borderLeft: '2px solid var(--brand-200)',
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 600, color: 'var(--ink-500)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.6,
|
||||
padding: '6px 2px 2px',
|
||||
}}>{ex.subLabel}</div>
|
||||
{ex.subOptions.map(sub => {
|
||||
const isSel = selection.subId === sub.id;
|
||||
return (
|
||||
<Row
|
||||
key={sub.id}
|
||||
selected={isSel}
|
||||
onClick={() => setState({ ...state, [ex.id]: { ...selection, subId: sub.id } })}
|
||||
left={<RadioDot selected={isSel} />}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 15, color: 'var(--ink-900)' }}>{sub.label}</div>
|
||||
{sub.price > 0 && (
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)' }}>+€{sub.price.toFixed(2)}</div>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// INGREDIENTS TAB — remove only
|
||||
// ===========================================================================
|
||||
function IngredientsTab({ ingredients, state, setState }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'var(--ink-100)',
|
||||
borderRadius: 10,
|
||||
fontSize: 13, color: 'var(--ink-700)',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
Tap to remove ingredients from this item.
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ingredients.map(ing => {
|
||||
const removed = !!state[ing.id];
|
||||
return (
|
||||
<Row
|
||||
key={ing.id}
|
||||
selected={false}
|
||||
onClick={() => setState({ ...state, [ing.id]: !removed })}
|
||||
right={
|
||||
<div style={{
|
||||
height: 36, padding: '0 14px',
|
||||
borderRadius: 18,
|
||||
background: removed ? 'var(--alert-500)' : 'white',
|
||||
border: '1.5px solid ' + (removed ? 'var(--alert-500)' : 'var(--ink-200)'),
|
||||
color: removed ? 'white' : 'var(--ink-700)',
|
||||
fontSize: 14, fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{removed ? 'Removed' : 'Remove'}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: 16, fontWeight: 500,
|
||||
color: removed ? 'var(--ink-400)' : 'var(--ink-900)',
|
||||
textDecoration: removed ? 'line-through' : 'none',
|
||||
}}>{ing.label}</div>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PREFERENCES TAB — radio groups
|
||||
// ===========================================================================
|
||||
function PreferencesTab({ preferences, state, setState }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{preferences.map(pref => {
|
||||
const selected = state[pref.id];
|
||||
return (
|
||||
<div key={pref.id}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'baseline', gap: 8,
|
||||
padding: '0 2px 10px',
|
||||
}}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ink-900)' }}>{pref.label}</div>
|
||||
{pref.required && (
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 700,
|
||||
color: 'var(--alert-500)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.6,
|
||||
}}>Required</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{pref.subOptions.map(sub => {
|
||||
const isSel = selected === sub.id;
|
||||
return (
|
||||
<Row
|
||||
key={sub.id}
|
||||
selected={isSel}
|
||||
onClick={() => setState({ ...state, [pref.id]: sub.id })}
|
||||
left={<RadioDot selected={isSel} />}
|
||||
right={
|
||||
sub.price !== 0 ? (
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-500)' }}>
|
||||
{fmtSigned(sub.price)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: 'var(--ink-900)' }}>{sub.label}</div>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// NOTES TAB
|
||||
// ===========================================================================
|
||||
const QUICK_NOTES = ['No eye contact 😅', 'Table is in a hurry', 'Customer is allergic', 'Cut in smaller pieces', 'Leave box open'];
|
||||
|
||||
function NotesTab({ note, setNote }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'var(--ink-100)',
|
||||
borderRadius: 10,
|
||||
fontSize: 13, color: 'var(--ink-700)',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
Anything specific for the kitchen. Short and clear works best.
|
||||
</div>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="e.g. Please cut in 12 slices, extra napkins..."
|
||||
rows={5}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--ink-900)',
|
||||
background: 'white',
|
||||
border: '1px solid var(--ink-200)',
|
||||
borderRadius: 12,
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6, marginTop: 20, marginBottom: 8 }}>
|
||||
Quick notes
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{QUICK_NOTES.map(q => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => setNote(note ? `${note}\n${q}` : q)}
|
||||
style={{
|
||||
height: 38, padding: '0 14px',
|
||||
borderRadius: 19,
|
||||
background: 'white',
|
||||
border: '1px solid var(--ink-200)',
|
||||
color: 'var(--ink-700)',
|
||||
fontSize: 14, fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>+ {q}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// SUMMARY TAB — final config review
|
||||
// ===========================================================================
|
||||
function SummaryTab({ product, config, lines, onJumpTab }) {
|
||||
const sectionStyle = { marginBottom: 22 };
|
||||
const headerStyle = {
|
||||
fontSize: 12, fontWeight: 700, color: 'var(--ink-500)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.8,
|
||||
padding: '0 2px 8px',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
};
|
||||
const editBtn = (tab) => (
|
||||
<button onClick={() => onJumpTab(tab)} style={{
|
||||
background: 'none', border: 'none',
|
||||
fontSize: 12, fontWeight: 700, color: 'var(--brand-700)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.8,
|
||||
cursor: 'pointer',
|
||||
}}>Edit</button>
|
||||
);
|
||||
|
||||
// Group summary lines
|
||||
const groups = {
|
||||
pref: lines.filter(l => l.group === 'pref'),
|
||||
quick: lines.filter(l => l.group === 'quick'),
|
||||
extra: lines.filter(l => l.group === 'extra'),
|
||||
removed: lines.filter(l => l.group === 'removed'),
|
||||
};
|
||||
|
||||
const LineItem = ({ l }) => (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'white',
|
||||
border: '1px solid var(--ink-100)',
|
||||
borderRadius: 10,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
minHeight: 48,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--ink-900)' }}>
|
||||
{l.qty > 1 && <span style={{ fontFamily: "'Geist Mono', monospace", color: 'var(--ink-500)', marginRight: 6 }}>{l.qty}×</span>}
|
||||
{l.label}
|
||||
</div>
|
||||
{l.detail && (
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)', marginTop: 2 }}>{l.detail}</div>
|
||||
)}
|
||||
</div>
|
||||
{l.price !== 0 && (
|
||||
<div style={{
|
||||
fontSize: 14, fontWeight: 600,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
color: l.price < 0 ? 'var(--alert-500)' : 'var(--ink-900)',
|
||||
}}>{fmtSigned(l.price)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const isEmpty = lines.length === 0 && !config.note;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Product summary header */}
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
background: 'var(--brand-50)',
|
||||
border: '1px solid var(--brand-200)',
|
||||
borderRadius: 12,
|
||||
marginBottom: 20,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<div style={{ fontSize: 32 }}>{product.emoji}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink-900)' }}>{product.name}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-700)', marginTop: 2 }}>Base €{product.price.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEmpty && (
|
||||
<div style={{
|
||||
padding: '28px 16px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--ink-500)',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
No customization yet. Switch to other tabs to add options.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.pref.length > 0 && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span>Preferences</span>
|
||||
{editBtn('preferences')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{groups.pref.map((l, i) => <LineItem key={i} l={l} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.quick.length > 0 && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span>Quick options</span>
|
||||
{editBtn('quick')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{groups.quick.map((l, i) => <LineItem key={i} l={l} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.extra.length > 0 && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span>Extras</span>
|
||||
{editBtn('extras')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{groups.extra.map((l, i) => <LineItem key={i} l={l} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.removed.length > 0 && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span>Removed</span>
|
||||
{editBtn('ingredients')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{groups.removed.map((l, i) => <LineItem key={i} l={l} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.note && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span>Note</span>
|
||||
{editBtn('notes')}
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '12px 14px',
|
||||
background: '#fffbeb',
|
||||
border: '1px solid #fde6a7',
|
||||
borderRadius: 10,
|
||||
fontSize: 15,
|
||||
color: 'var(--ink-900)',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>{config.note}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// MAIN DRAWER
|
||||
// ===========================================================================
|
||||
function OrderDrawer({ product, isOpen, onClose, onAddToOrder }) {
|
||||
// Tabs available for this product (hidden if empty)
|
||||
const tabs = React.useMemo(() => {
|
||||
const list = [];
|
||||
if (product.quickOptions?.length) list.push({ id: 'quick', label: 'Quick' });
|
||||
if (product.extras?.length) list.push({ id: 'extras', label: 'Extras' });
|
||||
if (product.ingredients?.length) list.push({ id: 'ingredients', label: 'Ingredients' });
|
||||
if (product.preferences?.length) list.push({ id: 'preferences', label: 'Preferences' });
|
||||
list.push({ id: 'notes', label: 'Notes' });
|
||||
list.push({ id: 'summary', label: 'Summary' });
|
||||
return list;
|
||||
}, [product]);
|
||||
|
||||
// --- STATE ---
|
||||
const initialPrefs = () => {
|
||||
const p = {};
|
||||
(product.preferences || []).forEach(pref => {
|
||||
const def = pref.subOptions.find(s => s.default);
|
||||
if (def) p[pref.id] = def.id;
|
||||
});
|
||||
return p;
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = React.useState(tabs[0].id);
|
||||
const [quick, setQuick] = React.useState({}); // { id: qty }
|
||||
const [extras, setExtras] = React.useState({}); // { id: {qty, subId} }
|
||||
const [extraExpanded, setExtraExpanded] = React.useState(null);
|
||||
const [removed, setRemoved] = React.useState({}); // { id: true }
|
||||
const [prefs, setPrefs] = React.useState(initialPrefs());
|
||||
const [note, setNote] = React.useState('');
|
||||
const [qty, setQty] = React.useState(1);
|
||||
|
||||
// Reset when drawer opens with a (possibly different) product
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setActiveTab(tabs[0].id);
|
||||
setQuick({});
|
||||
setExtras({});
|
||||
setExtraExpanded(null);
|
||||
setRemoved({});
|
||||
setPrefs(initialPrefs());
|
||||
setNote('');
|
||||
setQty(1);
|
||||
}
|
||||
}, [isOpen, product.id]);
|
||||
|
||||
const config = { quick, extras, removed, prefs, note };
|
||||
|
||||
// --- Derived: summary lines -------------------------------------------
|
||||
const { lines, itemPrice } = React.useMemo(() => {
|
||||
const L = [];
|
||||
let p = product.price;
|
||||
|
||||
// Preferences
|
||||
(product.preferences || []).forEach(pref => {
|
||||
const selId = prefs[pref.id];
|
||||
if (!selId) return;
|
||||
const sub = pref.subOptions.find(s => s.id === selId);
|
||||
if (!sub) return;
|
||||
if (!sub.default || sub.price !== 0) {
|
||||
L.push({
|
||||
group: 'pref',
|
||||
label: `${pref.label}: ${sub.label}`,
|
||||
qty: 1,
|
||||
price: sub.price,
|
||||
});
|
||||
}
|
||||
p += sub.price;
|
||||
});
|
||||
|
||||
// Quick
|
||||
(product.quickOptions || []).forEach(opt => {
|
||||
const q = quick[opt.id] || 0;
|
||||
if (q === 0) return;
|
||||
const linePrice = opt.price * q;
|
||||
L.push({
|
||||
group: 'quick',
|
||||
label: opt.label,
|
||||
qty: q,
|
||||
price: linePrice,
|
||||
});
|
||||
p += linePrice;
|
||||
});
|
||||
|
||||
// Extras
|
||||
(product.extras || []).forEach(ex => {
|
||||
const sel = extras[ex.id];
|
||||
if (!sel) return;
|
||||
const sub = ex.subOptions.find(s => s.id === sel.subId);
|
||||
const linePrice = (ex.price + (sub?.price || 0)) * sel.qty;
|
||||
L.push({
|
||||
group: 'extra',
|
||||
label: ex.label,
|
||||
detail: sub?.label,
|
||||
qty: sel.qty,
|
||||
price: linePrice,
|
||||
});
|
||||
p += linePrice;
|
||||
});
|
||||
|
||||
// Removed ingredients
|
||||
(product.ingredients || []).forEach(ing => {
|
||||
if (removed[ing.id]) {
|
||||
L.push({
|
||||
group: 'removed',
|
||||
label: `No ${ing.label.toLowerCase()}`,
|
||||
qty: 1,
|
||||
price: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { lines: L, itemPrice: p };
|
||||
}, [prefs, quick, extras, removed, product]);
|
||||
|
||||
const total = itemPrice * qty;
|
||||
const customizationCount = lines.length + (note ? 1 : 0);
|
||||
|
||||
// Backdrop click to close
|
||||
const handleBackdrop = (e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={handleBackdrop}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'rgba(16, 20, 24, 0.45)',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
pointerEvents: isOpen ? 'auto' : 'none',
|
||||
transition: 'opacity 260ms ease',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0, right: 0, bottom: 0,
|
||||
height: '90%',
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
|
||||
transition: 'transform 320ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
zIndex: 11,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 -8px 32px rgba(16, 20, 24, 0.18)',
|
||||
}}>
|
||||
{/* Grab handle */}
|
||||
<div style={{ padding: '8px 0 4px', display: 'flex', justifyContent: 'center' }}>
|
||||
<div style={{ width: 40, height: 4, borderRadius: 2, background: 'var(--ink-200)' }} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '8px 16px 0',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56, height: 56,
|
||||
borderRadius: 14,
|
||||
background: 'var(--brand-50)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 32,
|
||||
flexShrink: 0,
|
||||
}}>{product.emoji}</div>
|
||||
<div style={{ flex: 1, minWidth: 0, paddingTop: 2 }}>
|
||||
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--ink-900)', lineHeight: 1.2 }}>{product.name}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)', marginTop: 4, lineHeight: 1.4 }}>{product.desc}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 36, height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--ink-100)',
|
||||
border: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 6L18 18M6 18L18 6" stroke="var(--ink-700)" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{
|
||||
marginTop: 14,
|
||||
borderBottom: '1px solid var(--ink-100)',
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}>
|
||||
<div style={{ display: 'flex', padding: '0 16px', gap: 4, minWidth: 'max-content' }}>
|
||||
{tabs.map(t => {
|
||||
const active = activeTab === t.id;
|
||||
const badge = (
|
||||
t.id === 'quick' ? Object.values(quick).filter(v => v > 0).length :
|
||||
t.id === 'extras' ? Object.values(extras).filter(Boolean).length :
|
||||
t.id === 'ingredients' ? Object.values(removed).filter(Boolean).length :
|
||||
t.id === 'notes' ? (note ? 1 : 0) :
|
||||
t.id === 'summary' ? customizationCount :
|
||||
0
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
style={{
|
||||
padding: '14px 4px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: '2px solid ' + (active ? 'var(--brand-500)' : 'transparent'),
|
||||
color: active ? 'var(--brand-700)' : 'var(--ink-500)',
|
||||
fontSize: 15,
|
||||
fontWeight: active ? 700 : 500,
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
marginRight: 10,
|
||||
transition: 'color 120ms ease, border-color 120ms ease',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{badge > 0 && (
|
||||
<span style={{
|
||||
minWidth: 20, height: 20, padding: '0 6px',
|
||||
borderRadius: 10,
|
||||
background: active ? 'var(--brand-500)' : 'var(--ink-200)',
|
||||
color: active ? 'white' : 'var(--ink-700)',
|
||||
fontSize: 12, fontWeight: 700,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
}}>{badge}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px 16px 20px',
|
||||
background: 'var(--bg)',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}>
|
||||
{activeTab === 'quick' && <QuickOptionsTab options={product.quickOptions} state={quick} setState={setQuick} />}
|
||||
{activeTab === 'extras' && <ExtrasTab extras={product.extras} state={extras} setState={setExtras} expandedId={extraExpanded} setExpandedId={setExtraExpanded} />}
|
||||
{activeTab === 'ingredients' && <IngredientsTab ingredients={product.ingredients} state={removed} setState={setRemoved} />}
|
||||
{activeTab === 'preferences' && <PreferencesTab preferences={product.preferences} state={prefs} setState={setPrefs} />}
|
||||
{activeTab === 'notes' && <NotesTab note={note} setNote={setNote} />}
|
||||
{activeTab === 'summary' && <SummaryTab product={product} config={config} lines={lines} onJumpTab={setActiveTab} />}
|
||||
</div>
|
||||
|
||||
{/* Footer — qty stepper + Add to Order */}
|
||||
<div style={{
|
||||
padding: '12px 16px 18px',
|
||||
background: 'white',
|
||||
borderTop: '1px solid var(--ink-100)',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
boxShadow: '0 -4px 12px rgba(16, 20, 24, 0.04)',
|
||||
}}>
|
||||
<Stepper value={qty} onChange={setQty} min={1} size="lg" />
|
||||
<button
|
||||
onClick={() => onAddToOrder({ product, config, qty, total })}
|
||||
style={{
|
||||
flex: 1, height: 56,
|
||||
borderRadius: 28,
|
||||
background: 'var(--brand-500)',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
fontSize: 16, fontWeight: 700,
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 22px',
|
||||
transition: 'transform 100ms ease, background 120ms ease',
|
||||
}}
|
||||
onMouseDown={(e) => e.currentTarget.style.transform = 'scale(0.98)'}
|
||||
onMouseUp={(e) => e.currentTarget.style.transform = 'scale(1)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'}
|
||||
>
|
||||
<span>Add to order</span>
|
||||
<span style={{ fontFamily: "'Geist Mono', monospace" }}>€{total.toFixed(2)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
window.OrderDrawer = OrderDrawer;
|
||||
Reference in New Issue
Block a user