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:
2026-04-29 12:12:23 +03:00
parent defc49f84f
commit bb39088464
78 changed files with 24370 additions and 1358 deletions

View 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;