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 @@
{"sections":{"v1":{"labels":{"v1-grid":"Grid of 8 tables — mixed statuses"}}}}

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Daily Ops — SimplePOS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f4f4f2;
--surface: #ffffff;
--ink-900: #111315;
--ink-700: #2b2f33;
--ink-500: #5a6169;
--ink-400: #8a9099;
--ink-300: #b8bdc4;
--ink-200: #dfe2e6;
--ink-100: #edeff1;
--brand-50: #eef2fb;
--brand-100: #dfe6f7;
--brand-200: #c2cff0;
--brand-500: #3758c9;
--brand-700: #25409a;
/* Status palette — match table cards */
--open-50: #eef7f0;
--open-100: #d7ecdc;
--open-500: #2f9e5e;
--open-700: #1f7042;
--occ-50: #eef2fb;
--occ-100: #d7dff4;
--occ-500: #3758c9;
--occ-700: #25409a;
--res-50: #f4eefb;
--res-100: #e3d4f3;
--res-500: #7a44c9;
--res-700: #57309a;
--alert-50: #fdeeea;
--alert-100: #f8d2c8;
--alert-500: #d94b26;
--alert-700: #a5361b;
--dirty-50: #f6f1e8;
--dirty-100: #e8dcc3;
--dirty-500: #8a6d2b;
--dirty-700: #604a1d;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--ink-900);
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-feature-settings: 'ss01', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root { width: 100vw; height: 100vh; }
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-thumb { background: var(--ink-200); border-radius: 4px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="browser-window.jsx"></script>
<script type="text/babel" src="ops-data.jsx"></script>
<script type="text/babel" src="ops-ui.jsx"></script>
<script type="text/babel" src="ops-cards.jsx"></script>
<script type="text/babel" src="ops-shifts.jsx"></script>
<script type="text/babel" src="ops-layouts.jsx"></script>
<script type="text/babel" src="ops-app.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Order Drawer — SimplePOS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600&display=swap" rel="stylesheet">
<style>
:root {
/* Neutrals */
--bg: #f4f4f2;
--surface: #ffffff;
--ink-900: #111315;
--ink-700: #2b2f33;
--ink-500: #5a6169;
--ink-400: #8a9099;
--ink-300: #b8bdc4;
--ink-200: #dfe2e6;
--ink-100: #edeff1;
/* Brand — used for selection / primary CTA */
--brand-50: #eef2fb;
--brand-100: #dfe6f7;
--brand-200: #c2cff0;
--brand-500: #3758c9;
--brand-600: #2c48ac;
--brand-700: #25409a;
--alert-500: #d94b26;
--alert-700: #a5361b;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
width: 100vw; height: 100vh;
background: #e6e8ec;
color: var(--ink-900);
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-feature-settings: 'ss01', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
#root { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
/* Hide scrollbars inside the phone for cleaner look */
*::-webkit-scrollbar { display: none; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="ios-frame.jsx"></script>
<script type="text/babel" src="menu-data.jsx"></script>
<script type="text/babel" src="order-drawer.jsx"></script>
<script type="text/babel" src="order-app.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Table Cards — SimplePOS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500&display=swap" rel="stylesheet">
<style>
:root {
/* Neutrals */
--bg: #f4f4f2;
--surface: #ffffff;
--ink-900: #111315;
--ink-700: #2b2f33;
--ink-500: #5a6169;
--ink-400: #8a9099;
--ink-300: #b8bdc4;
--ink-200: #dfe2e6;
--ink-100: #edeff1;
/* Status palette — muted, POS-appropriate */
--open-50: #eef7f0;
--open-100: #d7ecdc;
--open-500: #2f9e5e;
--open-700: #1f7042;
--occ-50: #eef2fb;
--occ-100: #d7dff4;
--occ-500: #3758c9;
--occ-700: #25409a;
--res-50: #f4eefb;
--res-100: #e3d4f3;
--res-500: #7a44c9;
--res-700: #57309a;
--alert-50: #fdeeea;
--alert-100: #f8d2c8;
--alert-500: #d94b26;
--alert-700: #a5361b;
--dirty-50: #f6f1e8;
--dirty-100: #e8dcc3;
--dirty-500: #8a6d2b;
--dirty-700: #604a1d;
--radius: 14px;
--radius-sm: 8px;
--shadow-1: 0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03);
--shadow-2: 0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--ink-900);
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-feature-settings: 'ss01', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root { width: 100vw; height: 100vh; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="table-card.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>

115
CLAUDE_DESIGN/app.jsx Normal file
View File

@@ -0,0 +1,115 @@
// App — arranges the three table-card variations inside a design canvas.
// Each variation gets its own artboard showing a 4×2 mini-grid of mixed statuses
// so you can see how they read together, plus a separate artboard showing
// hover/pressed states.
const { DesignCanvas, DCSection, DCArtboard } = window;
const { TableCardV1, TableCardV2, TableCardV3 } = window;
// Shared dataset — same across all variations so they compare fairly
const TABLES = [
{ name: 'A1', status: 'occupied', amount: 84.50, occupiedMins: 42, waiters: ['Marco Riva'], flags: [] },
{ name: 'A2', status: 'occupied', amount: 127.20, occupiedMins: 98, waiters: ['Sofia Greco'], flags: ['VIP'] },
{ name: 'A3', status: 'open', amount: null, occupiedMins: null, waiters: [], flags: [] },
{ name: 'A4', status: 'alert', amount: 56.00, occupiedMins: 73, waiters: ['Luca Bianchi'], flags: ['Allergy'] },
{ name: 'B1', status: 'reserved', amount: null, occupiedMins: null, waiters: ['Elena Costa'], flags: ['Birthday'] },
{ name: 'B2', status: 'occupied', amount: 212.80, occupiedMins: 115,waiters: ['Marco Riva', 'Sofia Greco', 'Luca Bianchi', 'Elena Costa'], flags: ['VIP'] },
{ name: 'B3', status: 'dirty', amount: null, occupiedMins: null, waiters: [], flags: [] },
{ name: 'B4', status: 'occupied', amount: 38.00, occupiedMins: 14, waiters: ['Luca Bianchi', 'Elena Costa'], flags: [] },
];
// Interaction states preview — shows the SAME card in 4 states side by side
const STATE_CARD = { name: 'A2', status: 'occupied', amount: 127.20, occupiedMins: 98, waiters: ['Sofia Greco'], flags: ['VIP'] };
function TableGrid({ Card }) {
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 260px)',
gap: 16,
padding: 28,
background: 'var(--bg)',
}}>
{TABLES.map((t, i) => <Card key={i} {...t} />)}
</div>
);
}
function StatesStrip({ Card, label }) {
// Render four copies with simulated states by forcing CSS
return (
<div style={{ padding: 28, background: 'var(--bg)' }}>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(4, 260px)', gap: 24,
}}>
{['Default', 'Hover', 'Pressed', 'Selected'].map((state) => (
<div key={state} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{
fontSize: 12, fontWeight: 600, color: 'var(--ink-500)',
textTransform: 'uppercase', letterSpacing: 0.8,
}}>{state}</div>
<div className={`state-${state.toLowerCase()}`} style={{ position: 'relative' }}>
<Card {...STATE_CARD} />
{state === 'Selected' && (
<div style={{
position: 'absolute', inset: -3,
borderRadius: 17,
boxShadow: '0 0 0 3px var(--occ-500)',
pointerEvents: 'none',
}} />
)}
</div>
</div>
))}
</div>
{/* Style overrides to force hover/pressed on the middle two */}
<style>{`
.state-hover > button {
box-shadow: var(--shadow-2) !important;
transform: translateY(-2px) !important;
}
.state-pressed > button {
box-shadow: inset 0 2px 4px rgba(16,20,24,0.08) !important;
transform: translateY(1px) !important;
}
`}</style>
</div>
);
}
function App() {
return (
<DesignCanvas title="Table Card — Variations">
<DCSection id="v1" title="Variation 1 — Left accent, stacked layout">
<DCArtboard id="v1-grid" label="Grid of 8 tables — mixed statuses" width={1104} height={420}>
<TableGrid Card={TableCardV1} />
</DCArtboard>
<DCArtboard id="v1-states" label="Interactive states" width={1160} height={260}>
<StatesStrip Card={TableCardV1} label="V1" />
</DCArtboard>
</DCSection>
<DCSection id="v2" title="Variation 2 — Top stripe, big name">
<DCArtboard id="v2-grid" label="Grid of 8 tables — mixed statuses" width={1104} height={420}>
<TableGrid Card={TableCardV2} />
</DCArtboard>
<DCArtboard id="v2-states" label="Interactive states" width={1160} height={260}>
<StatesStrip Card={TableCardV2} label="V2" />
</DCArtboard>
</DCSection>
<DCSection id="v3" title="Variation 3 — Plaque badge">
<DCArtboard id="v3-grid" label="Grid of 8 tables — mixed statuses" width={1104} height={420}>
<TableGrid Card={TableCardV3} />
</DCArtboard>
<DCArtboard id="v3-states" label="Interactive states" width={1160} height={260}>
<StatesStrip Card={TableCardV3} label="V3" />
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

View File

@@ -0,0 +1,114 @@
// Chrome.jsx — Simplified Chrome browser window (dark theme, macOS)
// No dependencies, no image assets. All inline styles + inline SVG.
const CHROME_C = {
barBg: '#202124',
tabBg: '#35363a',
text: '#e8eaed',
dim: '#9aa0a6',
urlBg: '#282a2d',
};
function ChromeTrafficLights() {
return (
<div style={{ display: 'flex', gap: 8, padding: '0 14px' }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
</div>
);
}
// Single tab (active has curved scoops)
function ChromeTab({ title = 'New Tab', active = false }) {
const curve = (flip) => (
<svg width="8" height="10" viewBox="0 0 8 10"
style={{ position: 'absolute', bottom: 0, [flip ? 'right' : 'left']: -8, transform: flip ? 'scaleX(-1)' : 'none' }}>
<path d="M0 10C2 9 6 8 8 0V10H0Z" fill={CHROME_C.tabBg}/>
</svg>
);
return (
<div style={{
position: 'relative', height: 34, alignSelf: 'flex-end',
padding: '0 12px', display: 'flex', alignItems: 'center', gap: 8,
background: active ? CHROME_C.tabBg : 'transparent',
borderRadius: '8px 8px 0 0', minWidth: 120, maxWidth: 220,
fontFamily: 'system-ui, sans-serif', fontSize: 12,
color: active ? CHROME_C.text : CHROME_C.dim,
}}>
{active && curve(false)}
{active && curve(true)}
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#5f6368', flexShrink: 0 }} />
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{title}</span>
</div>
);
}
function ChromeTabBar({ tabs = [{ title: 'New Tab' }], activeIndex = 0 }) {
return (
<div style={{
display: 'flex', alignItems: 'center', height: 44,
background: CHROME_C.barBg, paddingRight: 8,
}}>
<ChromeTrafficLights />
<div style={{ display: 'flex', alignItems: 'flex-end', height: '100%', paddingLeft: 4, flex: 1 }}>
{tabs.map((t, i) => <ChromeTab key={i} title={t.title} active={i === activeIndex} />)}
</div>
</div>
);
}
function ChromeToolbar({ url = 'example.com' }) {
const iconDot = (
<div style={{
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 16, height: 16, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
</div>
);
return (
<div style={{
height: 40, background: CHROME_C.tabBg,
display: 'flex', alignItems: 'center', gap: 4, padding: '0 8px',
}}>
{iconDot}
{/* url bar */}
<div style={{
flex: 1, height: 30, borderRadius: 15, background: CHROME_C.urlBg,
display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px',
margin: '0 6px',
}}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
<span style={{
flex: 1, color: CHROME_C.text, fontSize: 13,
fontFamily: 'system-ui, sans-serif',
}}>{url}</span>
</div>
{iconDot}
</div>
);
}
function ChromeWindow({
tabs = [{ title: 'New Tab' }], activeIndex = 0, url = 'example.com',
width = 900, height = 600, children,
}) {
return (
<div style={{
width, height, borderRadius: 10, overflow: 'hidden',
boxShadow: '0 24px 80px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.1)',
display: 'flex', flexDirection: 'column', background: CHROME_C.tabBg,
}}>
<ChromeTabBar tabs={tabs} activeIndex={activeIndex} />
<ChromeToolbar url={url} />
<div style={{ flex: 1, background: '#fff', overflow: 'auto' }}>
{children}
</div>
</div>
);
}
Object.assign(window, {
ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights,
});

View File

@@ -0,0 +1,622 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
// State persists to a .design-canvas.state.json sidecar via the host
// bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
'.dc-card{transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
'[data-dc-slot]:hover .dc-expand{opacity:1}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, focused
// artboard). Order/titles/labels persist to a .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
// host's window.omelette bridge — editing requires the omelette runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
function DesignCanvas({ children, minScale, maxScale, style }) {
const [state, setState] = React.useState({ sections: {}, focus: null });
// Hold rendering until the sidecar read settles so the saved order/titles
// appear on first paint (no source-order flash). didRead gates writes until
// the read settles so the empty initial state can't clobber a slow read;
// skipNextWrite suppresses the one echo-write that would otherwise follow
// hydration.
const [ready, setReady] = React.useState(false);
const didRead = React.useRef(false);
const skipNextWrite = React.useRef(false);
React.useEffect(() => {
let off = false;
fetch('./' + DC_STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((saved) => {
if (off || !saved || !saved.sections) return;
skipNextWrite.current = true;
setState((s) => ({ ...s, sections: saved.sections }));
})
.catch(() => {})
.finally(() => { didRead.current = true; if (!off) setReady(true); });
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
return () => { off = true; clearTimeout(t); };
}, []);
React.useEffect(() => {
if (!didRead.current) return;
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
const t = setTimeout(() => {
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
}, 250);
return () => clearTimeout(t);
}, [state.sections]);
// Build registries synchronously from children so FocusOverlay can read
// them in the same render. Only direct DCSection > DCArtboard children are
// walked — wrapping them in other elements opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
React.Children.forEach(children, (sec) => {
if (!sec || sec.type !== DCSection) return;
const sid = sec.props.id ?? sec.props.title;
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const srcIds = [];
React.Children.forEach(sec.props.children, (ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (!aid) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
sectionMeta[sid] = {
title: persisted.title ?? sec.props.title,
subtitle: sec.props.subtitle,
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
};
});
const api = React.useMemo(() => ({
state,
section: (id) => state.sections[id] || {},
patchSection: (id, p) => setState((s) => ({
...s,
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
})),
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
}), [state]);
// Esc exits focus; any outside pointerdown commits an in-progress rename.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
const onPd = (e) => {
const ae = document.activeElement;
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPd, true);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPd, true);
};
}, [api]);
return (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
}, []);
React.useEffect(() => {
const vp = vpRef.current;
if (!vp) return;
const zoomAt = (cx, cy, factor) => {
const r = vp.getBoundingClientRect();
const px = cx - r.left, py = cy - r.top;
const t = tf.current;
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
const k = next / t.scale;
// keep the world point under the cursor fixed
t.x = px - (px - t.x) * k;
t.y = py - (py - t.y) * k;
t.scale = next;
apply();
};
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if (e.ctrlKey) {
// trackpad pinch (or explicit ctrl+wheel)
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
// Drag-pan: middle button anywhere, or primary button on canvas
// background (anything that isn't an artboard or an inline editor).
let drag = null;
const onPointerDown = (e) => {
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
e.preventDefault();
vp.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
vp.style.cursor = 'grabbing';
};
const onPointerMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
tf.current.x += e.clientX - drag.lx;
tf.current.y += e.clientY - drag.ly;
drag.lx = e.clientX; drag.ly = e.clientY;
apply();
};
const onPointerUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
vp.releasePointerCapture(e.pointerId);
drag = null;
vp.style.cursor = '';
};
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
vp.addEventListener('pointerdown', onPointerDown);
vp.addEventListener('pointermove', onPointerMove);
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', onPointerDown);
vp.removeEventListener('pointermove', onPointerMove);
vp.removeEventListener('pointerup', onPointerUp);
vp.removeEventListener('pointercancel', onPointerUp);
};
}, [apply, minScale, maxScale]);
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
return (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(children);
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
const sec = (ctx && sid && ctx.section(sid)) || {};
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
}, [sec.order, srcOrder.join('|')]);
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
return (
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
<div style={{ padding: '0 60px 56px' }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
// changes on drop.
const onGripDown = (e) => {
e.preventDefault(); e.stopPropagation();
const me = ref.current;
// translateX is applied in local (pre-scale) space but pointer deltas and
// getBoundingClientRect().left are screen-space — divide by the viewport's
// current scale so the dragged card tracks the cursor at any zoom level.
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
const slotXs = homes.map((h) => h.x);
const startIdx = order.indexOf(id);
const startX = e.clientX;
let liveOrder = order.slice();
me.classList.add('dc-dragging');
const layout = () => {
for (const h of homes) {
if (h.id === id) continue;
const slot = liveOrder.indexOf(h.id);
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
}
};
const move = (ev) => {
const dx = ev.clientX - startX;
me.style.transform = `translateX(${dx / scale}px)`;
const cur = homes[startIdx].x + dx;
let nearest = 0, best = Infinity;
for (let i = 0; i < slotXs.length; i++) {
const d = Math.abs(slotXs[i] - cur);
if (d < best) { best = d; nearest = i; }
}
if (liveOrder.indexOf(id) !== nearest) {
liveOrder = order.filter((k) => k !== id);
liveOrder.splice(nearest, 0, id);
layout();
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const finalSlot = liveOrder.indexOf(id);
me.classList.remove('dc-dragging');
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
// After the settle transition, kill transitions + clear transforms +
// commit the reorder in the same frame so there's no visual snap-back.
setTimeout(() => {
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const h of homes) h.el.style.transition = '';
}));
}, 180);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
};
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
<div className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) ctx.setFocus(`${ns}/${first}`);
};
React.useEffect(() => {
const k = (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
});
const { width = 260, height = 480, children } = artboard.props;
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
const [ddOpen, setDd] = React.useState(false);
const Arrow = ({ dir, onClick }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });

338
CLAUDE_DESIGN/ios-frame.jsx Normal file
View File

@@ -0,0 +1,338 @@
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
// Exports: IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard
// ─────────────────────────────────────────────────────────────
// Status bar
// ─────────────────────────────────────────────────────────────
function IOSStatusBar({ dark = false, time = '9:41' }) {
const c = dark ? '#fff' : '#000';
return (
<div style={{
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
padding: '21px 24px 19px', boxSizing: 'border-box',
position: 'relative', zIndex: 20, width: '100%',
}}>
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
<span style={{
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
fontSize: 17, lineHeight: '22px', color: c,
}}>{time}</span>
</div>
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
<svg width="19" height="12" viewBox="0 0 19 12">
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
</svg>
<svg width="17" height="12" viewBox="0 0 17 12">
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
</svg>
<svg width="27" height="13" viewBox="0 0 27 13">
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
</svg>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Liquid glass pill — blur + tint + shine
// ─────────────────────────────────────────────────────────────
function IOSGlassPill({ children, dark = false, style = {} }) {
return (
<div style={{
height: 44, minWidth: 44, borderRadius: 9999,
position: 'relative', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: dark
? '0 2px 6px rgba(0,0,0,0.35), 0 6px 16px rgba(0,0,0,0.2)'
: '0 1px 3px rgba(0,0,0,0.07), 0 3px 10px rgba(0,0,0,0.06)',
...style,
}}>
{/* blur + tint */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 9999,
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
background: dark ? 'rgba(120,120,128,0.28)' : 'rgba(255,255,255,0.5)',
}} />
{/* shine */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 9999,
boxShadow: dark
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15), inset -1px -1px 1px rgba(255,255,255,0.08)'
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
}} />
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', padding: '0 4px' }}>
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Navigation bar — glass pills + large title
// ─────────────────────────────────────────────────────────────
function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) {
const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040';
const text = dark ? '#fff' : '#000';
const pillIcon = (content) => (
<IOSGlassPill dark={dark}>
<div style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{content}
</div>
</IOSGlassPill>
);
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 10,
paddingTop: 62, paddingBottom: 10, position: 'relative', zIndex: 5,
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 16px',
}}>
{/* back chevron */}
{pillIcon(
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" style={{ marginLeft: -1 }}>
<path d="M10 2L2 10l8 8" stroke={muted} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{/* trailing ellipsis */}
{trailingIcon && pillIcon(
<svg width="22" height="6" viewBox="0 0 22 6">
<circle cx="3" cy="3" r="2.5" fill={muted}/>
<circle cx="11" cy="3" r="2.5" fill={muted}/>
<circle cx="19" cy="3" r="2.5" fill={muted}/>
</svg>
)}
</div>
{/* large title */}
<div style={{
padding: '0 16px',
fontFamily: '-apple-system, system-ui',
fontSize: 34, fontWeight: 700, lineHeight: '41px',
color: text, letterSpacing: 0.4,
}}>{title}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Grouped list (inset card, r:26) + row (52px)
// ─────────────────────────────────────────────────────────────
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
const text = dark ? '#fff' : '#000';
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
return (
<div style={{
display: 'flex', alignItems: 'center', minHeight: 52,
padding: '0 16px', position: 'relative',
fontFamily: '-apple-system, system-ui', fontSize: 17,
letterSpacing: -0.43,
}}>
{icon && (
<div style={{
width: 30, height: 30, borderRadius: 7, background: icon,
marginRight: 12, flexShrink: 0,
}} />
)}
<div style={{ flex: 1, color: text }}>{title}</div>
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
{chevron && (
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{!isLast && (
<div style={{
position: 'absolute', bottom: 0, right: 0,
left: icon ? 58 : 16, height: 0.5, background: sep,
}} />
)}
</div>
);
}
function IOSList({ header, children, dark = false }) {
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
const bg = dark ? '#1C1C1E' : '#fff';
return (
<div>
{header && (
<div style={{
fontFamily: '-apple-system, system-ui', fontSize: 13,
color: hc, textTransform: 'uppercase',
padding: '8px 36px 6px', letterSpacing: -0.08,
}}>{header}</div>
)}
<div style={{
background: bg, borderRadius: 26,
margin: '0 16px', overflow: 'hidden',
}}>{children}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Device frame
// ─────────────────────────────────────────────────────────────
function IOSDevice({
children, width = 402, height = 874, dark = false,
title, keyboard = false,
}) {
return (
<div style={{
width, height, borderRadius: 48, overflow: 'hidden',
position: 'relative', background: dark ? '#000' : '#F2F2F7',
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)',
fontFamily: '-apple-system, system-ui, sans-serif',
WebkitFontSmoothing: 'antialiased',
}}>
{/* dynamic island */}
<div style={{
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
}} />
{/* status bar (absolute) */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
<IOSStatusBar dark={dark} />
</div>
{/* nav + content */}
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{title !== undefined && <IOSNavBar title={title} dark={dark} />}
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
{keyboard && <IOSKeyboard dark={dark} />}
</div>
{/* home indicator — always on top */}
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
paddingBottom: 8, pointerEvents: 'none',
}}>
<div style={{
width: 139, height: 5, borderRadius: 100,
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
}} />
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Keyboard — iOS 26 liquid glass
// ─────────────────────────────────────────────────────────────
function IOSKeyboard({ dark = false }) {
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
// special-key icons
const icons = {
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
};
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
<div key={k} style={{
height: 42, borderRadius: 8.5,
flex: flex ? 1 : undefined, width: w, minWidth: 0,
background: ret ? '#08f' : keyBg,
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: '-apple-system, "SF Compact", system-ui',
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
}}>{content}</div>
);
const row = (keys, pad = 0) => (
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
{keys.map(l => key(l, { flex: true, k: l }))}
</div>
);
return (
<div style={{
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
padding: '11px 0 2px',
display: 'flex', flexDirection: 'column', alignItems: 'center',
boxShadow: dark
? '0 -2px 20px rgba(0,0,0,0.09)'
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
}}>
{/* liquid glass bg — same recipe as nav pills */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 27,
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
}} />
<div style={{
position: 'absolute', inset: 0, borderRadius: 27,
boxShadow: dark
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
pointerEvents: 'none',
}} />
{/* autocorrect bar */}
<div style={{
display: 'flex', gap: 20, alignItems: 'center',
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
position: 'relative',
}}>
{['"The"', 'the', 'to'].map((w, i) => (
<React.Fragment key={i}>
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
<div style={{
flex: 1, textAlign: 'center',
fontFamily: '-apple-system, system-ui', fontSize: 17,
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
}}>{w}</div>
</React.Fragment>
))}
</div>
{/* key layout */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 13,
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
position: 'relative',
}}>
{row(['q','w','e','r','t','y','u','i','o','p'])}
{row(['a','s','d','f','g','h','j','k','l'], 20)}
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
{key(icons.shift, { w: 45, k: 'shift' })}
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
</div>
{key(icons.del, { w: 45, k: 'del' })}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
{key('', { flex: true, k: 'space' })}
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
</div>
</div>
{/* bottom spacer (emoji+mic area, icons omitted) */}
<div style={{ height: 56, width: '100%', position: 'relative' }} />
</div>
);
}
Object.assign(window, {
IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard,
});

122
CLAUDE_DESIGN/menu-data.jsx Normal file
View File

@@ -0,0 +1,122 @@
// Menu data + product under customization
const MENU = [
{ id: 'margherita', name: 'Pizza Margherita', desc: 'Tomato, mozzarella, basil', price: 9.50, emoji: '🍕' },
{ id: 'diavola', name: 'Pizza Diavola', desc: 'Spicy salami, mozzarella, chili oil', price: 12.00, emoji: '🌶️' },
{ id: 'quattro', name: 'Quattro Formaggi', desc: 'Mozzarella, gorgonzola, fontina, parmesan', price: 13.50, emoji: '🧀' },
{ id: 'prosciutto', name: 'Prosciutto & Funghi', desc: 'Ham, mushrooms, mozzarella', price: 12.50, emoji: '🍄' },
{ id: 'tiramisu', name: 'Tiramisù', desc: 'House-made, classic recipe', price: 6.50, emoji: '🍰' },
{ id: 'coke', name: 'Coca-Cola', desc: '330ml can', price: 3.00, emoji: '🥤' },
];
// Full product spec for Diavola — our drawer target
const DIAVOLA = {
id: 'diavola',
name: 'Pizza Diavola',
desc: 'Spicy salami, mozzarella, chili oil, San Marzano tomato',
price: 12.00,
emoji: '🌶️',
// ---- Quick Options -----------------------------------------------------
// Fast preferences / add-ons. Some allow multiple, some carry a cost.
quickOptions: [
{ id: 'well-done', label: 'Well done', price: 0, multi: false },
{ id: 'light-bake', label: 'Light bake', price: 0, multi: false },
{ id: 'cut-8', label: 'Cut in 8 slices', price: 0, multi: false },
{ id: 'extra-cheese', label: 'Extra cheese', price: 1.50, multi: true },
{ id: 'extra-salami', label: 'Extra salami', price: 2.00, multi: true },
{ id: 'gluten-free', label: 'Gluten-free base', price: 3.00, multi: false },
],
// ---- Extras with sub-options -----------------------------------------
// Can add multiple. Each has a flavor/variant to pick.
extras: [
{
id: 'dip',
label: 'Dipping sauce',
price: 0.80,
multi: true,
subLabel: 'Choose a sauce',
subOptions: [
{ id: 'garlic', label: 'Garlic', price: 0 },
{ id: 'bbq', label: 'BBQ', price: 0 },
{ id: 'ranch', label: 'Ranch', price: 0 },
{ id: 'truffle', label: 'Truffle mayo', price: 0.50 },
{ id: 'spicy', label: 'Spicy arrabbiata', price: 0 },
],
},
{
id: 'cheese-edge',
label: 'Stuffed crust',
price: 2.50,
multi: false,
subLabel: 'Choose a filling',
subOptions: [
{ id: 'mozz', label: 'Mozzarella', price: 0 },
{ id: 'gorg', label: 'Gorgonzola', price: 0.50 },
{ id: 'nduja', label: "'Nduja", price: 1.00 },
],
},
{
id: 'oil',
label: 'Drizzle of oil',
price: 0.50,
multi: true,
subLabel: 'Choose an oil',
subOptions: [
{ id: 'evoo', label: 'Extra virgin', price: 0 },
{ id: 'chili', label: 'Chili-infused', price: 0 },
{ id: 'truffle', label: 'Truffle', price: 1.20 },
{ id: 'basil', label: 'Basil', price: 0.30 },
],
},
],
// ---- Ingredients (removable only) ------------------------------------
ingredients: [
{ id: 'salami', label: 'Spicy salami' },
{ id: 'mozz', label: 'Mozzarella' },
{ id: 'tomato', label: 'Tomato sauce' },
{ id: 'chili', label: 'Chili oil' },
{ id: 'basil', label: 'Basil leaves' },
{ id: 'oregano', label: 'Oregano' },
],
// ---- Preferences with sub-options ------------------------------------
preferences: [
{
id: 'size',
label: 'Size',
required: true,
subOptions: [
{ id: 'small', label: 'Small (26cm)', price: -2.00, default: false },
{ id: 'medium', label: 'Medium (32cm)', price: 0, default: true },
{ id: 'large', label: 'Large (40cm)', price: 4.00, default: false },
],
},
{
id: 'crust',
label: 'Crust style',
required: false,
subOptions: [
{ id: 'classic', label: 'Classic', price: 0, default: true },
{ id: 'thin', label: 'Thin & crispy', price: 0, default: false },
{ id: 'sourdough', label: 'Sourdough', price: 1.50, default: false },
],
},
{
id: 'spice',
label: 'Spice level',
required: false,
subOptions: [
{ id: 'mild', label: 'Mild', price: 0, default: false },
{ id: 'medium', label: 'Medium', price: 0, default: true },
{ id: 'hot', label: 'Hot', price: 0, default: false },
{ id: 'xxx', label: 'Extra hot', price: 0, default: false },
],
},
],
};
window.MENU = MENU;
window.DIAVOLA = DIAVOLA;

51
CLAUDE_DESIGN/ops-app.jsx Normal file
View File

@@ -0,0 +1,51 @@
// Top-level — design canvas with desktop and tablet artboards
const { DesignCanvas, DCSection, DCArtboard } = window;
const { ChromeWindow } = window;
const { DesktopDashboard, TabletDashboard } = window.OpsLayouts;
// Tablet bezel — simple frame, since we want to communicate "tablet"
function TabletShell({ children }) {
return (
<div style={{
width: 900, height: 1180,
background: '#1a1a1a',
borderRadius: 36,
padding: 14,
boxShadow: '0 20px 50px rgba(0,0,0,0.18)',
}}>
<div style={{
width: '100%', height: '100%',
background: 'white', borderRadius: 24,
overflow: 'hidden',
}}>{children}</div>
</div>
);
}
function App() {
return (
<DesignCanvas title="Daily Ops Dashboard — Desktop + Tablet">
<DCSection id="desktop" title="Desktop — primary view">
<DCArtboard id="desktop-main" label="1440×900 — full operational view, mid-shift" width={1440} height={900}>
<ChromeWindow url="manage.simplepos.com/today" tabs={[{ title: 'Today · Trattoria del Sole' }]} width={1440} height={900}>
<DesktopDashboard />
</ChromeWindow>
</DCArtboard>
</DCSection>
<DCSection id="tablet" title="Tablet — portrait">
<DCArtboard id="tablet-main" label="Tablet portrait — same data, vertical stack" width={930} height={1210}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: 'transparent' }}>
<TabletShell>
<TabletDashboard />
</TabletShell>
</div>
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

199
CLAUDE_DESIGN/ops-cards.jsx Normal file
View File

@@ -0,0 +1,199 @@
// Dashboard cards — KPIs, tables overview, hourly chart, reservations
const { Avatar, Card, StatPill, Btn, Icon } = window.OpsUI;
const { OPS_DATA } = window;
// ---------------------------------------------------------------- KPI big card
function KpiCard({ label, value, sub, delta, accent = 'var(--brand-500)', tone, children }) {
return (
<div style={{
background: tone || 'white',
border: '1px solid var(--ink-100)',
borderRadius: 16,
padding: 22,
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
display: 'flex', flexDirection: 'column', gap: 8,
minHeight: 148,
position: 'relative',
overflow: 'hidden',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{
fontSize: 13, fontWeight: 700, color: 'var(--ink-500)',
textTransform: 'uppercase', letterSpacing: 0.6,
}}>{label}</div>
<StatPill delta={delta} />
</div>
<div style={{
fontSize: 38, fontWeight: 700,
fontFamily: "'Geist Mono', monospace",
color: 'var(--ink-900)',
letterSpacing: -1,
lineHeight: 1.1,
}}>{value}</div>
{sub && <div style={{ fontSize: 13, color: 'var(--ink-500)' }}>{sub}</div>}
{children}
</div>
);
}
// Progress bar used inside KPI cards
function ProgressBar({ pct, color = 'var(--brand-500)' }) {
return (
<div style={{
marginTop: 'auto', height: 8, borderRadius: 4,
background: 'var(--ink-100)', overflow: 'hidden',
}}>
<div style={{
width: `${Math.min(100, pct)}%`, height: '100%',
background: color, borderRadius: 4,
transition: 'width 240ms ease',
}} />
</div>
);
}
// ---------------------------------------------------------------- Tables
function TablesOverview() {
// Build a synthetic 18-table layout
const states = {
'A1': 'occupied', 'A2': 'occupied', 'A3': 'open', 'A4': 'alert',
'B1': 'reserved','B2': 'occupied', 'B3': 'open', 'B4': 'occupied',
'C1': 'open', 'C2': 'reserved','C3': 'open', 'C4': 'open',
'D1': 'occupied','D2': 'dirty', 'D3': 'open', 'D4': 'open',
'T1': 'occupied','T2': 'open',
};
const colors = {
occupied: { bg: 'var(--occ-100)', fg: 'var(--occ-700)', accent: 'var(--occ-500)' },
open: { bg: 'var(--open-50)', fg: 'var(--open-700)', accent: 'var(--open-500)' },
reserved: { bg: 'var(--res-100)', fg: 'var(--res-700)', accent: 'var(--res-500)' },
alert: { bg: 'var(--alert-100)', fg: 'var(--alert-700)', accent: 'var(--alert-500)' },
dirty: { bg: 'var(--dirty-100)', fg: 'var(--dirty-700)', accent: 'var(--dirty-500)' },
};
const counts = Object.values(states).reduce((acc, s) => (acc[s] = (acc[s] || 0) + 1, acc), {});
return (
<Card title="Tables right now" action={<Btn size="sm" variant="ghost">View floor</Btn>}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(6, 1fr)',
gap: 8,
marginBottom: 16,
}}>
{Object.entries(states).map(([name, status]) => {
const c = colors[status];
return (
<div key={name} style={{
aspectRatio: '1',
minHeight: 44,
borderRadius: 10,
background: c.bg,
color: c.fg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, fontWeight: 700,
fontFamily: "'Geist Mono', monospace",
border: '1px solid ' + c.accent + '33',
}}>{name}</div>
);
})}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{[
['occupied', 'Occupied'],
['open', 'Open'],
['reserved', 'Reserved'],
['alert', 'Alert'],
['dirty', 'Cleaning'],
].map(([k, label]) => (
<div key={k} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '5px 10px',
borderRadius: 999,
background: 'var(--ink-100)',
fontSize: 12, fontWeight: 600,
color: 'var(--ink-700)',
}}>
<span style={{ width: 8, height: 8, borderRadius: 4, background: colors[k].accent }} />
{label}
<span style={{ fontFamily: "'Geist Mono', monospace", color: 'var(--ink-900)' }}>{counts[k] || 0}</span>
</div>
))}
</div>
</Card>
);
}
// ---------------------------------------------------------------- Hourly chart
function HourlyRevenueCard() {
const data = OPS_DATA.hourly;
const max = Math.max(...data.map(d => d.revenue), 800);
const currentHour = '19';
return (
<Card title="Revenue by hour" action={<div style={{ fontSize: 13, color: 'var(--ink-500)', fontWeight: 500 }}>Today</div>}>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 140, padding: '8px 0 0' }}>
{data.map(d => {
const h = max > 0 ? (d.revenue / max) * 100 : 0;
const isCurrent = d.hour === currentHour;
const isFuture = parseInt(d.hour) > parseInt(currentHour);
return (
<div key={d.hour} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, height: '100%' }}>
<div style={{ flex: 1, width: '100%', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
<div style={{
width: '100%', height: `${h}%`,
borderRadius: 6,
minHeight: d.revenue > 0 ? 4 : 0,
background: isCurrent
? 'var(--brand-500)'
: isFuture
? 'var(--ink-100)'
: 'var(--brand-200)',
}} />
</div>
<div style={{
fontSize: 11,
fontFamily: "'Geist Mono', monospace",
color: isCurrent ? 'var(--brand-700)' : 'var(--ink-500)',
fontWeight: isCurrent ? 700 : 500,
}}>{d.hour}</div>
</div>
);
})}
</div>
</Card>
);
}
// ---------------------------------------------------------------- Reservations
function ReservationsCard() {
return (
<Card title="Reservations today" action={<Btn size="sm" variant="ghost">+ Add</Btn>}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 280, overflowY: 'auto' }}>
{OPS_DATA.reservations.map(r => (
<div key={r.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px',
borderRadius: 10,
border: '1px solid var(--ink-100)',
}}>
<div style={{
width: 56, textAlign: 'center',
fontSize: 16, fontWeight: 700,
fontFamily: "'Geist Mono', monospace",
color: 'var(--ink-900)',
flexShrink: 0,
}}>{r.time}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink-900)' }}>{r.name}</div>
<div style={{ fontSize: 12, color: 'var(--ink-500)', marginTop: 1 }}>
{r.guests} guests · Table {r.table}
{r.notes && <> · <span style={{ color: 'var(--brand-700)', fontWeight: 600 }}>{r.notes}</span></>}
</div>
</div>
</div>
))}
</div>
</Card>
);
}
window.OpsCards = { KpiCard, ProgressBar, TablesOverview, HourlyRevenueCard, ReservationsCard };

View File

@@ -0,0 +1,73 @@
// Mock data for the daily ops dashboard
window.OPS_DATA = {
business: {
name: 'Trattoria del Sole',
date: 'Saturday, April 25',
dayStartedAt: '11:30',
dayDurationMins: 4 * 60 + 18, // 4h 18m so far
},
kpis: {
revenue: 2847.50,
revenueGoal: 4500,
revenueLastWeek: 2640.00,
covers: 64,
coversGoal: 110,
coversLastWeek: 71,
tablesOpen: 7,
tablesTotal: 18,
avgTicket: 44.49,
},
hourly: [
// Lunch service mostly done, dinner ramping up
{ hour: '11', revenue: 0 },
{ hour: '12', revenue: 480 },
{ hour: '13', revenue: 720 },
{ hour: '14', revenue: 410 },
{ hour: '15', revenue: 180 },
{ hour: '16', revenue: 90 },
{ hour: '17', revenue: 240 },
{ hour: '18', revenue: 580 },
{ hour: '19', revenue: 147 },
{ hour: '20', revenue: 0 },
{ hour: '21', revenue: 0 },
{ hour: '22', revenue: 0 },
],
shifts: [
{ id: 1, name: 'Marco Riva', section: 'Terrace', clockIn: '11:00', hoursWorked: 4.8, tables: ['A1', 'A2'], status: 'active' },
{ id: 2, name: 'Sofia Greco', section: 'Main hall', clockIn: '11:00', hoursWorked: 4.8, tables: ['B2'], status: 'active' },
{ id: 3, name: 'Luca Bianchi', section: 'Main hall', clockIn: '11:30', hoursWorked: 4.3, tables: ['A4', 'B4'], status: 'break' },
{ id: 4, name: 'Elena Costa', section: 'Bar', clockIn: '12:00', hoursWorked: 3.8, tables: ['B1', 'B2'], status: 'active' },
{ id: 5, name: 'Alessandro Conti', section: 'Terrace', clockIn: '17:00', hoursWorked: 0.3, tables: [], status: 'active' },
],
scheduledShifts: [
{ id: 6, name: 'Giulia Ferri', section: 'Main hall', scheduledAt: '18:00' },
{ id: 7, name: 'Paolo Mancini', section: 'Bar', scheduledAt: '18:30' },
],
reservations: [
{ id: 1, time: '19:00', name: 'Bianchi', guests: 4, table: 'A1', notes: 'Anniversary' },
{ id: 2, time: '19:30', name: 'Romano', guests: 2, table: 'B3', notes: '' },
{ id: 3, time: '20:00', name: 'De Luca', guests: 6, table: 'C1', notes: 'High chair' },
{ id: 4, time: '20:00', name: 'Ferrari', guests: 2, table: '—', notes: 'VIP' },
{ id: 5, time: '20:30', name: 'Russo', guests: 8, table: 'C2', notes: 'Birthday' },
{ id: 6, time: '21:00', name: 'Marino', guests: 3, table: 'A3', notes: '' },
],
recentMessages: [
{ id: 1, to: 'Marco Riva', text: 'Come see me', sentAt: '15:42', read: true },
{ id: 2, to: 'Everyone', text: 'Specials updated — see kitchen', sentAt: '15:10', read: true },
{ id: 3, to: 'Luca Bianchi', text: 'Table A4 needs cleaning', sentAt: '14:55', read: false },
],
presets: [
'Come see me',
'Table __ needs you',
'Table __ needs cleaning',
'Take a break',
],
};

View File

@@ -0,0 +1,217 @@
// Main ops dashboard layout
const { Avatar, Card, Btn, Icon } = window.OpsUI;
const { KpiCard, ProgressBar, TablesOverview, HourlyRevenueCard, ReservationsCard } = window.OpsCards;
const { ShiftsCard, MessagesCard, ComposeModal, EndDayModal } = window.OpsCards2;
const { OPS_DATA } = window;
function TopBar({ onEndDay }) {
const b = OPS_DATA.business;
return (
<div style={{
padding: '20px 28px',
background: 'white',
borderBottom: '1px solid var(--ink-100)',
display: 'flex', alignItems: 'center', gap: 20,
}}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: 'var(--ink-900)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: 18,
fontFamily: "'Geist Mono', monospace", flexShrink: 0,
}}>TS</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--ink-900)' }}>{b.name}</div>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 10px', borderRadius: 999,
background: 'var(--open-50)', color: 'var(--open-700)',
fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.6,
}}>
<span style={{ width: 6, height: 6, borderRadius: 3, background: 'var(--open-500)' }} />
Day open
</span>
</div>
<div style={{ fontSize: 13, color: 'var(--ink-500)', marginTop: 2 }}>
{b.date} · started at {b.dayStartedAt} · {Math.floor(b.dayDurationMins/60)}h {b.dayDurationMins%60}m running
</div>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<Btn variant="secondary" size="md"><Icon name="bell" size={16} /></Btn>
<Btn variant="secondary" size="md">Reports</Btn>
<Btn variant="danger" size="md" onClick={onEndDay}><Icon name="stop" size={14} color="white" /> End day</Btn>
</div>
</div>
);
}
// Compact tablet top bar
function TabletTopBar({ onEndDay }) {
const b = OPS_DATA.business;
return (
<div style={{
padding: '16px 20px',
background: 'white',
borderBottom: '1px solid var(--ink-100)',
display: 'flex', alignItems: 'center', gap: 14,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-900)' }}>{b.name}</div>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '3px 8px', borderRadius: 999,
background: 'var(--open-50)', color: 'var(--open-700)',
fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5,
}}>
<span style={{ width: 5, height: 5, borderRadius: 3, background: 'var(--open-500)' }} />
Open
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--ink-500)', marginTop: 2 }}>
{b.date} · {b.dayStartedAt} · {Math.floor(b.dayDurationMins/60)}h {b.dayDurationMins%60}m
</div>
</div>
<Btn variant="secondary" size="sm"><Icon name="bell" size={14} /></Btn>
<Btn variant="danger" size="sm" onClick={onEndDay}><Icon name="stop" size={12} color="white" /> End day</Btn>
</div>
);
}
// KPI strip — 3 cards
function KpiStrip({ compact }) {
const k = OPS_DATA.kpis;
const revPct = (k.revenue / k.revenueGoal) * 100;
const covPct = (k.covers / k.coversGoal) * 100;
const tablesOpenPct = (k.tablesOpen / k.tablesTotal) * 100;
const revDelta = ((k.revenue - k.revenueLastWeek) / k.revenueLastWeek) * 100;
const covDelta = ((k.covers - k.coversLastWeek) / k.coversLastWeek) * 100;
return (
<div style={{
display: 'grid',
gridTemplateColumns: compact ? '1fr 1fr 1fr' : '1fr 1fr 1fr',
gap: compact ? 12 : 20,
}}>
<KpiCard
label="Revenue today"
value={'€' + OPS_DATA.kpis.revenue.toLocaleString('en', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
sub={`Goal €${k.revenueGoal.toLocaleString()} · ${revPct.toFixed(0)}%`}
delta={revDelta}
>
<ProgressBar pct={revPct} color="var(--brand-500)" />
</KpiCard>
<KpiCard
label="Covers"
value={k.covers.toString()}
sub={`Avg €${k.avgTicket.toFixed(2)} per cover · ${covPct.toFixed(0)}%`}
delta={covDelta}
>
<ProgressBar pct={covPct} color="var(--occ-500)" />
</KpiCard>
<KpiCard
label="Tables"
value={`${k.tablesOpen} / ${k.tablesTotal}`}
sub={`${k.tablesTotal - k.tablesOpen} closed · ${tablesOpenPct.toFixed(0)}% in use`}
>
<ProgressBar pct={tablesOpenPct} color="var(--res-500)" />
</KpiCard>
</div>
);
}
// ---------------------------------------------------------------- DESKTOP LAYOUT
function DesktopDashboard() {
const [composeOpen, setComposeOpen] = React.useState(false);
const [composeText, setComposeText] = React.useState('');
const [composeTo, setComposeTo] = React.useState('Everyone');
const [endDayOpen, setEndDayOpen] = React.useState(false);
const openCompose = (preset, to) => {
setComposeText(preset || '');
setComposeTo(to || 'Everyone');
setComposeOpen(true);
};
return (
<div style={{
width: '100%', height: '100%',
background: 'var(--bg)',
display: 'flex', flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}>
<TopBar onEndDay={() => setEndDayOpen(true)} />
<div style={{ flex: 1, overflowY: 'auto', padding: 28 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 18 }}>
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--ink-900)' }}>Today at a glance</div>
<div style={{ fontSize: 13, color: 'var(--ink-500)' }}>Updated 19:08 · auto-refresh</div>
</div>
<KpiStrip />
<div style={{
marginTop: 20,
display: 'grid',
gridTemplateColumns: '1.2fr 1fr',
gap: 20,
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<TablesOverview />
<ShiftsCard onMessage={(s) => openCompose('', s.name)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<HourlyRevenueCard />
<MessagesCard openCompose={openCompose} />
<ReservationsCard />
</div>
</div>
</div>
<ComposeModal open={composeOpen} prefilled={composeText} prefilledTo={composeTo} onClose={() => setComposeOpen(false)} />
<EndDayModal open={endDayOpen} onClose={() => setEndDayOpen(false)} onConfirm={() => setEndDayOpen(false)} />
</div>
);
}
// ---------------------------------------------------------------- TABLET LAYOUT
function TabletDashboard() {
const [composeOpen, setComposeOpen] = React.useState(false);
const [composeText, setComposeText] = React.useState('');
const [composeTo, setComposeTo] = React.useState('Everyone');
const [endDayOpen, setEndDayOpen] = React.useState(false);
const openCompose = (preset, to) => {
setComposeText(preset || '');
setComposeTo(to || 'Everyone');
setComposeOpen(true);
};
return (
<div style={{
width: '100%', height: '100%',
background: 'var(--bg)',
display: 'flex', flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}>
<TabletTopBar onEndDay={() => setEndDayOpen(true)} />
<div style={{ flex: 1, overflowY: 'auto', padding: 18 }}>
<KpiStrip compact />
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 14 }}>
<TablesOverview />
<HourlyRevenueCard />
<ShiftsCard onMessage={(s) => openCompose('', s.name)} />
<MessagesCard openCompose={openCompose} />
<ReservationsCard />
</div>
</div>
<ComposeModal open={composeOpen} prefilled={composeText} prefilledTo={composeTo} onClose={() => setComposeOpen(false)} />
<EndDayModal open={endDayOpen} onClose={() => setEndDayOpen(false)} onConfirm={() => setEndDayOpen(false)} />
</div>
);
}
window.OpsLayouts = { DesktopDashboard, TabletDashboard };

View File

@@ -0,0 +1,283 @@
// Shifts card + Messages card
const { Avatar, Card, Btn, Icon } = window.OpsUI;
const { OPS_DATA } = window;
// ---------------------------------------------------------------- Shifts
function ShiftsCard({ onMessage }) {
return (
<Card
title="Shifts on now"
action={<Btn size="sm" variant="secondary">+ Start shift</Btn>}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{OPS_DATA.shifts.map(s => (
<div key={s.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px',
borderRadius: 12,
border: '1px solid var(--ink-100)',
background: s.status === 'break' ? '#fbf6ec' : 'white',
}}>
<Avatar name={s.name} size={42} status={s.status} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ink-900)' }}>{s.name}</div>
{s.status === 'break' && (
<span style={{
padding: '2px 8px', borderRadius: 999,
background: 'var(--dirty-100)', color: 'var(--dirty-700)',
fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.4,
}}>On break</span>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-500)', marginTop: 2 }}>
{s.section} · in at {s.clockIn} · {s.hoursWorked.toFixed(1)}h worked
{s.tables.length > 0 && <> · tables {s.tables.join(', ')}</>}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button onClick={() => onMessage(s)} title="Message" style={{
width: 36, height: 36, borderRadius: 18,
background: 'white', border: '1px solid var(--ink-200)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--ink-700)',
}}><Icon name="chat" size={16} /></button>
<button title="More" style={{
width: 36, height: 36, borderRadius: 18,
background: 'white', border: '1px solid var(--ink-200)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--ink-700)',
fontSize: 18, lineHeight: 1, paddingBottom: 6,
}}></button>
</div>
</div>
))}
{/* scheduled / not yet started */}
<div style={{
marginTop: 6,
fontSize: 11, fontWeight: 700, color: 'var(--ink-400)',
textTransform: 'uppercase', letterSpacing: 0.6,
padding: '4px 4px 0',
}}>Scheduled later</div>
{OPS_DATA.scheduledShifts.map(s => (
<div key={s.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px',
borderRadius: 12,
border: '1px dashed var(--ink-200)',
background: 'var(--bg)',
}}>
<Avatar name={s.name} size={42} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ink-700)' }}>{s.name}</div>
<div style={{ fontSize: 12, color: 'var(--ink-500)', marginTop: 2 }}>
{s.section} · starts at {s.scheduledAt}
</div>
</div>
<Btn size="sm" variant="primary">Start now</Btn>
</div>
))}
</div>
</Card>
);
}
// ---------------------------------------------------------------- Messages
function MessagesCard({ openCompose }) {
return (
<Card
title="Messages"
action={<Btn size="sm" variant="primary" onClick={() => openCompose()}><Icon name="send" size={14} /> New</Btn>}
>
{/* Quick presets */}
<div style={{
fontSize: 11, fontWeight: 700, color: 'var(--ink-400)',
textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 8,
}}>Quick send</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 18 }}>
{OPS_DATA.presets.map(p => (
<button key={p} onClick={() => openCompose(p)} style={{
height: 34, padding: '0 12px',
borderRadius: 17,
background: 'white',
border: '1px solid var(--ink-200)',
color: 'var(--ink-700)',
fontSize: 13, fontWeight: 500,
cursor: 'pointer',
fontFamily: 'inherit',
}}>+ {p}</button>
))}
</div>
<div style={{
fontSize: 11, fontWeight: 700, color: 'var(--ink-400)',
textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 8,
}}>Recent</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{OPS_DATA.recentMessages.map(m => (
<div key={m.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 4px',
}}>
<Avatar name={m.to === 'Everyone' ? 'All' : m.to} size={28} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: 'var(--ink-900)' }}>
<span style={{ fontWeight: 600 }}>{m.to}</span>
<span style={{ color: 'var(--ink-500)' }}> · {m.text}</span>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--ink-400)', fontFamily: "'Geist Mono', monospace" }}>{m.sentAt}</div>
{!m.read && <div style={{ width: 8, height: 8, borderRadius: 4, background: 'var(--brand-500)' }} />}
</div>
))}
</div>
</Card>
);
}
// ---------------------------------------------------------------- Compose modal
function ComposeModal({ open, prefilled, prefilledTo, onClose }) {
const [text, setText] = React.useState('');
const [recipient, setRecipient] = React.useState('Everyone');
React.useEffect(() => {
if (open) {
setText(prefilled || '');
setRecipient(prefilledTo || 'Everyone');
}
}, [open, prefilled, prefilledTo]);
if (!open) return null;
const recipients = ['Everyone', ...OPS_DATA.shifts.map(s => s.name)];
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0,
background: 'rgba(16,20,24,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50,
padding: 20,
}}>
<div onClick={(e) => e.stopPropagation()} style={{
width: 'min(520px, 100%)',
background: 'white',
borderRadius: 18,
padding: 24,
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--ink-900)' }}>New message</div>
<button onClick={onClose} style={{
width: 36, height: 36, borderRadius: 18,
border: 'none', background: 'var(--ink-100)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}><Icon name="x" size={16} /></button>
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 8 }}>To</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 18 }}>
{recipients.map(r => (
<button key={r} onClick={() => setRecipient(r)} style={{
height: 36, padding: '0 14px',
borderRadius: 18,
background: recipient === r ? 'var(--brand-500)' : 'white',
border: '1px solid ' + (recipient === r ? 'var(--brand-500)' : 'var(--ink-200)'),
color: recipient === r ? 'white' : 'var(--ink-700)',
fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>{r}</button>
))}
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 8 }}>Message</div>
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3}
placeholder="Type a message..."
style={{
width: '100%', padding: 14, fontSize: 16, fontFamily: 'inherit',
border: '1px solid var(--ink-200)', borderRadius: 12, resize: 'none',
outline: 'none', boxSizing: 'border-box',
}}/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 12 }}>
{OPS_DATA.presets.map(p => (
<button key={p} onClick={() => setText(p)} style={{
height: 32, padding: '0 12px',
borderRadius: 16,
background: 'var(--ink-100)',
border: 'none',
color: 'var(--ink-700)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>{p}</button>
))}
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 22 }}>
<Btn variant="secondary" onClick={onClose}>Cancel</Btn>
<Btn variant="primary" size="lg" onClick={onClose}><Icon name="send" size={16} color="white" /> Send</Btn>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------- End-day modal
function EndDayModal({ open, onClose, onConfirm }) {
if (!open) return null;
const k = OPS_DATA.kpis;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0,
background: 'rgba(16,20,24,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50, padding: 20,
}}>
<div onClick={(e) => e.stopPropagation()} style={{
width: 'min(560px, 100%)',
background: 'white', borderRadius: 18, padding: 28,
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
}}>
<div style={{ fontSize: 22, fontWeight: 700, color: 'var(--ink-900)', marginBottom: 6 }}>End business day?</div>
<div style={{ fontSize: 14, color: 'var(--ink-500)', marginBottom: 22 }}>
Today's session will close. Make sure all tables are paid and waiters have clocked out.
</div>
<div style={{
padding: '16px 18px',
background: 'var(--bg)', borderRadius: 12,
marginBottom: 22,
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14,
}}>
{[
['Revenue', '' + k.revenue.toFixed(2)],
['Covers', k.covers.toString()],
['Open tables', `${k.tablesOpen} of ${k.tablesTotal}`],
['Avg ticket', '' + k.avgTicket.toFixed(2)],
].map(([lbl, val]) => (
<div key={lbl}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>{lbl}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: 'var(--ink-900)', fontFamily: "'Geist Mono', monospace", marginTop: 2 }}>{val}</div>
</div>
))}
</div>
{k.tablesOpen > 0 && (
<div style={{
padding: '12px 14px',
background: 'var(--alert-50)', border: '1px solid var(--alert-100)',
borderRadius: 10, marginBottom: 22,
fontSize: 14, color: 'var(--alert-700)', fontWeight: 600,
}}>
⚠ {k.tablesOpen} tables are still open. They'll need to be closed manually.
</div>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<Btn variant="secondary" onClick={onClose}>Cancel</Btn>
<Btn variant="danger" size="lg" onClick={onConfirm}>End day</Btn>
</div>
</div>
</div>
);
}
window.OpsCards2 = { ShiftsCard, MessagesCard, ComposeModal, EndDayModal };

145
CLAUDE_DESIGN/ops-ui.jsx Normal file
View File

@@ -0,0 +1,145 @@
// Shared primitives for the ops dashboard
function Avatar({ name, size = 36, status }) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775'];
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
const bg = palette[h % palette.length];
const parts = name.split(' ');
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
return (
<div style={{ position: 'relative', flexShrink: 0 }}>
<div style={{
width: size, height: size, borderRadius: '50%',
background: bg, color: 'white',
fontSize: size * 0.4, fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{initials}</div>
{status && (
<div style={{
position: 'absolute', bottom: -1, right: -1,
width: size * 0.32, height: size * 0.32,
borderRadius: '50%',
background: status === 'active' ? 'var(--open-500)' : status === 'break' ? 'var(--dirty-500)' : 'var(--ink-300)',
border: '2px solid white',
}} />
)}
</div>
);
}
function Card({ title, action, children, padding = 22, style }) {
return (
<div style={{
background: 'white',
border: '1px solid var(--ink-100)',
borderRadius: 16,
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
display: 'flex', flexDirection: 'column',
...style,
}}>
{title && (
<div style={{
padding: `18px ${padding}px 0`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink-900)', textTransform: 'uppercase', letterSpacing: 0.6 }}>{title}</div>
{action}
</div>
)}
<div style={{ padding: padding, paddingTop: title ? 14 : padding, flex: 1 }}>{children}</div>
</div>
);
}
function StatPill({ delta }) {
if (delta == null) return null;
const up = delta > 0;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '3px 8px',
borderRadius: 999,
background: up ? 'var(--open-50)' : 'var(--alert-50)',
color: up ? 'var(--open-700)' : 'var(--alert-700)',
fontSize: 12, fontWeight: 600,
}}>
<span style={{ fontSize: 10 }}>{up ? '▲' : '▼'}</span>
{Math.abs(delta).toFixed(1)}%
</span>
);
}
function Stepper({ value, onChange, min = 0, max = 99 }) {
return (
<div style={{
display: 'inline-flex', alignItems: 'center',
height: 36, borderRadius: 18,
background: 'white', border: '1px solid var(--ink-200)',
}} onClick={(e) => e.stopPropagation()}>
<button onClick={() => onChange(Math.max(min, value - 1))} disabled={value <= min}
style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 18, color: value <= min ? 'var(--ink-300)' : 'var(--ink-900)', cursor: value <= min ? 'default' : 'pointer' }}></button>
<div style={{ minWidth: 26, textAlign: 'center', fontSize: 15, fontWeight: 600, fontFamily: "'Geist Mono', monospace" }}>{value}</div>
<button onClick={() => onChange(Math.min(max, value + 1))} style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 18, cursor: 'pointer' }}>+</button>
</div>
);
}
function Btn({ children, variant = 'secondary', size = 'md', onClick, style }) {
const variants = {
primary: { bg: 'var(--brand-500)', fg: 'white', bd: 'var(--brand-500)' },
danger: { bg: 'var(--alert-500)', fg: 'white', bd: 'var(--alert-500)' },
secondary: { bg: 'white', fg: 'var(--ink-900)', bd: 'var(--ink-200)' },
ghost: { bg: 'transparent', fg: 'var(--ink-700)', bd: 'transparent' },
};
const sizes = {
sm: { h: 32, px: 12, fs: 13 },
md: { h: 40, px: 16, fs: 14 },
lg: { h: 48, px: 22, fs: 15 },
};
const v = variants[variant];
const s = sizes[size];
return (
<button onClick={onClick} style={{
height: s.h, padding: `0 ${s.px}px`,
borderRadius: s.h / 2,
background: v.bg, color: v.fg,
border: '1px solid ' + v.bd,
fontSize: s.fs, fontWeight: 600,
fontFamily: 'inherit',
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 8,
whiteSpace: 'nowrap',
...style,
}}>{children}</button>
);
}
function Icon({ name, size = 20, color = 'currentColor' }) {
const paths = {
play: 'M8 5V19L19 12L8 5Z',
stop: 'M6 6H18V18H6V6Z',
bell: 'M12 22C13.1 22 14 21.1 14 20H10C10 21.1 10.9 22 12 22ZM18 16V11C18 7.9 16.4 5.4 13.5 4.7V4C13.5 3.2 12.8 2.5 12 2.5C11.2 2.5 10.5 3.2 10.5 4V4.7C7.6 5.4 6 7.9 6 11V16L4 18V19H20V18L18 16Z',
plus: 'M12 5V19M5 12H19',
chat: 'M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2Z',
clock: 'M12 22C17.5 22 22 17.5 22 12C22 6.5 17.5 2 12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22ZM12.5 7H11V13L16.2 16.2L17 14.9L12.5 12.2V7Z',
coffee: 'M2 21H20V19H2V21ZM20 8H18V5H4V13C4 15.2 5.8 17 8 17H14C16.2 17 18 15.2 18 13V10H20C21.1 10 22 9.1 22 8V8C22 6.9 21.1 8 20 8Z',
users: 'M16 11C17.7 11 19 9.7 19 8C19 6.3 17.7 5 16 5C14.3 5 13 6.3 13 8C13 9.7 14.3 11 16 11ZM8 11C9.7 11 11 9.7 11 8C11 6.3 9.7 5 8 5C6.3 5 5 6.3 5 8C5 9.7 6.3 11 8 11ZM8 13C5.3 13 0 14.3 0 17V19H16V17C16 14.3 10.7 13 8 13ZM16 13C15.7 13 15.3 13 14.9 13.1C16.2 14 17 15.3 17 17V19H24V17C24 14.3 18.7 13 16 13Z',
table: 'M3 5C3 3.9 3.9 3 5 3H19C20.1 3 21 3.9 21 5V19C21 20.1 20.1 21 19 21H5C3.9 21 3 20.1 3 19V5ZM5 11H19V5H5V11ZM5 13V19H11V13H5ZM13 13V19H19V13H13Z',
check: 'M9 16.2L4.8 12L3.4 13.4L9 19L21 7L19.6 5.6L9 16.2Z',
x: 'M19 6.4L17.6 5L12 10.6L6.4 5L5 6.4L10.6 12L5 17.6L6.4 19L12 13.4L17.6 19L19 17.6L13.4 12L19 6.4Z',
chevron: 'M9 6L15 12L9 18',
send: 'M2 21L23 12L2 3V10L17 12L2 14V21Z',
pause: 'M6 4H10V20H6V4ZM14 4H18V20H14V4Z',
search: 'M15.5 14H14.7L14.4 13.7C15.4 12.5 16 10.8 16 9C16 5.1 12.9 2 9 2C5.1 2 2 5.1 2 9C2 12.9 5.1 16 9 16C10.8 16 12.5 15.4 13.7 14.4L14 14.7V15.5L19 20.5L20.5 19L15.5 14Z',
bolt: 'M11 21H10L11 14H7.5C7 14 7 13.7 7.1 13.5L13 3H14L13 10H16.5C16.9 10 17 10.2 16.9 10.5L11 21Z',
sun: 'M12 7C9.2 7 7 9.2 7 12C7 14.8 9.2 17 12 17C14.8 17 17 14.8 17 12C17 9.2 14.8 7 12 7ZM2 13H4C4.6 13 5 12.6 5 12C5 11.4 4.6 11 4 11H2C1.4 11 1 11.4 1 12C1 12.6 1.4 13 2 13ZM20 13H22C22.6 13 23 12.6 23 12C23 11.4 22.6 11 22 11H20C19.4 11 19 11.4 19 12C19 12.6 19.4 13 20 13ZM11 2V4C11 4.6 11.4 5 12 5C12.6 5 13 4.6 13 4V2C13 1.4 12.6 1 12 1C11.4 1 11 1.4 11 2ZM11 20V22C11 22.6 11.4 23 12 23C12.6 23 13 22.6 13 22V20C13 19.4 12.6 19 12 19C11.4 19 11 19.4 11 20ZM5.99 4.58C5.6 4.19 4.96 4.19 4.58 4.58C4.19 4.97 4.19 5.6 4.58 5.99L5.64 7.05C6.03 7.44 6.66 7.44 7.05 7.05C7.44 6.66 7.44 6.03 7.05 5.64L5.99 4.58ZM18.36 16.95C17.97 16.56 17.34 16.56 16.95 16.95C16.56 17.34 16.56 17.97 16.95 18.36L18.01 19.42C18.4 19.81 19.04 19.81 19.42 19.42C19.81 19.03 19.81 18.4 19.42 18.01L18.36 16.95Z',
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d={paths[name]} stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
window.OpsUI = { Avatar, Card, StatPill, Stepper, Btn, Icon };

185
CLAUDE_DESIGN/order-app.jsx Normal file
View File

@@ -0,0 +1,185 @@
// Main app — menu list in an iOS frame, tap an item to open the drawer
const { IOSDevice } = window;
const { MENU, DIAVOLA, OrderDrawer } = window;
function MenuItemRow({ item, onTap, badge }) {
const [pressed, setPressed] = React.useState(false);
return (
<div
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
onMouseLeave={() => setPressed(false)}
onClick={onTap}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 16px',
background: pressed ? 'var(--ink-100)' : 'white',
border: '1px solid var(--ink-100)',
borderRadius: 14,
cursor: 'pointer',
transition: 'background 120ms ease, transform 100ms ease',
transform: pressed ? 'scale(0.99)' : 'scale(1)',
position: 'relative',
}}
>
<div style={{
width: 52, height: 52,
borderRadius: 12,
background: 'var(--brand-50)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 28,
flexShrink: 0,
}}>{item.emoji}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 15, fontWeight: 600, color: 'var(--ink-900)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{item.name}</div>
<div style={{
fontSize: 13, color: 'var(--ink-500)', marginTop: 2,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{item.desc}</div>
</div>
<div style={{
fontSize: 15, fontWeight: 600, color: 'var(--ink-900)',
fontFamily: "'Geist Mono', monospace",
flexShrink: 0,
}}>{item.price.toFixed(2)}</div>
{badge > 0 && (
<div style={{
position: 'absolute', top: -6, right: -6,
minWidth: 22, height: 22, padding: '0 6px',
borderRadius: 11,
background: 'var(--brand-500)',
color: 'white',
fontSize: 12, fontWeight: 700,
fontFamily: "'Geist Mono', monospace",
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 2px 6px rgba(58, 88, 201, 0.35)',
}}>{badge}</div>
)}
</div>
);
}
function MenuScreen({ onTapItem, orderCounts }) {
const categories = [
{ label: 'All', active: true },
{ label: 'Pizza' },
{ label: 'Pasta' },
{ label: 'Desserts' },
{ label: 'Drinks' },
];
return (
<div style={{
height: '100%',
display: 'flex', flexDirection: 'column',
background: 'var(--bg)',
}}>
{/* Top bar */}
<div style={{
padding: '12px 16px 8px',
background: 'white',
borderBottom: '1px solid var(--ink-100)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div style={{
padding: '6px 10px',
background: 'var(--brand-50)',
borderRadius: 8,
fontSize: 12, fontWeight: 700, color: 'var(--brand-700)',
fontFamily: "'Geist Mono', monospace",
}}>TABLE B2</div>
<div style={{ fontSize: 13, color: 'var(--ink-500)' }}>· 4 guests · Marco</div>
<div style={{ flex: 1 }} />
<div style={{
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
}}>Cart <span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: 20, height: 20, padding: '0 6px',
borderRadius: 10, background: 'var(--ink-900)', color: 'white',
fontSize: 11, fontWeight: 700, marginLeft: 4,
fontFamily: "'Geist Mono', monospace",
}}>3</span></div>
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: 'var(--ink-900)', marginBottom: 10 }}>Add item</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', scrollbarWidth: 'none', paddingBottom: 2 }}>
{categories.map(c => (
<button key={c.label} style={{
padding: '8px 14px',
borderRadius: 18,
background: c.active ? 'var(--ink-900)' : 'white',
border: '1px solid ' + (c.active ? 'var(--ink-900)' : 'var(--ink-200)'),
color: c.active ? 'white' : 'var(--ink-700)',
fontSize: 14, fontWeight: 600,
cursor: 'pointer',
fontFamily: 'inherit',
whiteSpace: 'nowrap',
}}>{c.label}</button>
))}
</div>
</div>
{/* Menu list */}
<div style={{ flex: 1, overflowY: 'auto', padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
{MENU.map(item => (
<MenuItemRow
key={item.id}
item={item}
badge={orderCounts[item.id] || 0}
onTap={() => onTapItem(item)}
/>
))}
</div>
{/* Hint banner when drawer not open */}
<div style={{
padding: '10px 16px',
background: 'var(--brand-50)',
borderTop: '1px solid var(--brand-200)',
textAlign: 'center',
fontSize: 13,
color: 'var(--brand-700)',
fontWeight: 600,
}}>Tap "Pizza Diavola" to open the drawer</div>
</div>
);
}
function App() {
const [drawerOpen, setDrawerOpen] = React.useState(true); // open by default so the design is immediately visible
const [orderCounts, setOrderCounts] = React.useState({ margherita: 2, coke: 1 });
// Tapping a menu item: for this demo only Diavola has a full spec,
// so we always feed the drawer the Diavola config but show the tap behavior.
const openDrawer = (_item) => setDrawerOpen(true);
const closeDrawer = () => setDrawerOpen(false);
const handleAdd = ({ product, qty }) => {
setOrderCounts(prev => ({ ...prev, [product.id]: (prev[product.id] || 0) + qty }));
setDrawerOpen(false);
};
return (
<div style={{ width: '100%', height: '100%', background: 'var(--bg)' }}>
<IOSDevice>
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
<MenuScreen onTapItem={openDrawer} orderCounts={orderCounts} />
<OrderDrawer
product={DIAVOLA}
isOpen={drawerOpen}
onClose={closeDrawer}
onAddToOrder={handleAdd}
/>
</div>
</IOSDevice>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

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;

View File

@@ -0,0 +1,478 @@
// Table Card — 3 variations
// All cards share the same fixed dimensions and field positions so the grid
// stays visually aligned even when fields are empty.
const STATUS = {
open: { label: 'Open', tint: 'var(--open-50)', tintStrong: 'var(--open-100)', accent: 'var(--open-500)', ink: 'var(--open-700)' },
occupied:{ label: 'Occupied', tint: 'var(--occ-50)', tintStrong: 'var(--occ-100)', accent: 'var(--occ-500)', ink: 'var(--occ-700)' },
reserved:{ label: 'Reserved', tint: 'var(--res-50)', tintStrong: 'var(--res-100)', accent: 'var(--res-500)', ink: 'var(--res-700)' },
alert: { label: 'Needs attention', tint: 'var(--alert-50)', tintStrong: 'var(--alert-100)', accent: 'var(--alert-500)', ink: 'var(--alert-700)' },
dirty: { label: 'Needs cleaning', tint: 'var(--dirty-50)', tintStrong: 'var(--dirty-100)', accent: 'var(--dirty-500)', ink: 'var(--dirty-700)' },
};
// ----- Shared helpers -----
function formatEuro(n) {
if (n == null) return null;
return '€' + n.toFixed(2).replace(/\.00$/, '.00');
}
function formatDuration(mins) {
if (mins == null) return null;
if (mins < 60) return `${mins}m`;
const h = Math.floor(mins / 60);
const m = mins % 60;
return m === 0 ? `${h}h` : `${h}h ${m}m`;
}
function avatarColor(name) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775'];
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
function Initials({ name, size = 28 }) {
const parts = name.split(' ');
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(name),
color: 'white',
fontSize: size * 0.42,
fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 0 0 2px var(--cardBg, white)',
}}>{initials}</div>
);
}
function Flag({ kind }) {
const map = {
VIP: { bg: '#fff4d6', fg: '#8a6d0b', label: 'VIP' },
Allergy: { bg: '#fde3dc', fg: '#a5361b', label: 'Allergy' },
Birthday: { bg: '#fbe2ee', fg: '#a8276b', label: 'Birthday' },
};
const s = map[kind] || { bg: '#eceff2', fg: '#3a4049', label: kind };
return (
<span style={{
display: 'inline-flex', alignItems: 'center',
height: 22, padding: '0 8px',
borderRadius: 999,
background: s.bg, color: s.fg,
fontSize: 12, fontWeight: 600,
letterSpacing: 0.2,
whiteSpace: 'nowrap',
}}>{s.label}</span>
);
}
// Placeholder dashes so empty fields keep their footprint but visually disappear
function EmptyDash({ width = 40 }) {
return <span style={{ color: 'var(--ink-300)', letterSpacing: 2, userSelect: 'none' }}> </span>;
}
// ===========================================================================
// VARIATION 1 — Left accent border + tinted background
// Stacked: header row, stats row, waiter row. Clean and quiet.
// ===========================================================================
function TableCardV1({ name, status, amount, occupiedMins, waiters = [], flags = [] }) {
const s = STATUS[status];
const [hover, setHover] = React.useState(false);
const [pressed, setPressed] = React.useState(false);
// Waiter display rules
const showMulti = waiters.length >= 3;
return (
<button
type="button"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPressed(false); }}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
style={{
'--cardBg': s.tint,
position: 'relative',
width: 260, height: 180,
padding: '16px 18px 16px 22px',
background: s.tint,
border: '1px solid ' + s.tintStrong,
borderRadius: 'var(--radius)',
boxShadow: pressed ? 'inset 0 2px 4px rgba(16,20,24,0.08)' : (hover ? 'var(--shadow-2)' : 'var(--shadow-1)'),
transform: pressed ? 'translateY(1px)' : (hover ? 'translateY(-2px)' : 'translateY(0)'),
transition: 'transform 120ms ease, box-shadow 120ms ease',
cursor: 'pointer',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
display: 'flex', flexDirection: 'column',
outline: 'none',
}}
>
{/* left accent bar */}
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
background: s.accent,
borderRadius: 'var(--radius) 0 0 var(--radius)',
}} />
{/* Header row: name + status pill */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{
fontSize: 32, fontWeight: 700, lineHeight: 1,
letterSpacing: -0.5,
color: 'var(--ink-900)',
fontFamily: "'Geist Mono', 'Geist', monospace",
}}>{name}</div>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 26, padding: '0 10px',
borderRadius: 999,
background: s.accent,
color: 'white',
fontSize: 12, fontWeight: 600,
letterSpacing: 0.2,
whiteSpace: 'nowrap',
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
{s.label}
</div>
</div>
{/* Flags row — fixed height whether or not flags exist */}
<div style={{ marginTop: 8, height: 22, display: 'flex', gap: 6, alignItems: 'center' }}>
{flags.map(f => <Flag key={f} kind={f} />)}
</div>
{/* Stats row: amount + time. Fixed layout, empty dashes when missing */}
<div style={{
marginTop: 'auto',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
alignItems: 'end',
}}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--ink-900)', marginTop: 2, fontFamily: "'Geist Mono', 'Geist', monospace" }}>
{amount != null ? formatEuro(amount) : <EmptyDash />}
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
<div style={{
fontSize: 20, marginTop: 2,
fontFamily: "'Geist Mono', 'Geist', monospace",
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
color: 'var(--ink-900)',
}}>
{occupiedMins != null ? formatDuration(occupiedMins) : <EmptyDash />}
</div>
</div>
</div>
{/* Waiter row */}
<div style={{
marginTop: 12,
paddingTop: 10,
borderTop: '1px solid ' + s.tintStrong,
height: 36,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{waiters.length === 0 ? (
<span style={{ color: 'var(--ink-400)', fontSize: 13 }}>Unassigned</span>
) : showMulti ? (
<>
<div style={{ display: 'flex' }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
<Initials name={w} size={24} />
</div>
))}
</div>
<span style={{
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
background: 'white', border: '1px solid var(--ink-200)',
borderRadius: 999, padding: '2px 8px',
}}>Multiple ({waiters.length})</span>
</>
) : (
waiters.map((w, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Initials name={w} size={24} />
<span style={{ fontSize: 14, color: 'var(--ink-700)', fontWeight: 500 }}>{w.split(' ')[0]}</span>
</div>
))
)}
</div>
</button>
);
}
// ===========================================================================
// VARIATION 2 — Top stripe + large name on left, stats on right
// More "at-a-glance" — big name dominates.
// ===========================================================================
function TableCardV2({ name, status, amount, occupiedMins, waiters = [], flags = [] }) {
const s = STATUS[status];
const [hover, setHover] = React.useState(false);
const [pressed, setPressed] = React.useState(false);
const showMulti = waiters.length >= 3;
return (
<button
type="button"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPressed(false); }}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
style={{
'--cardBg': s.tint,
position: 'relative',
width: 260, height: 180,
padding: 0,
background: s.tint,
border: '1px solid ' + s.tintStrong,
borderRadius: 'var(--radius)',
boxShadow: pressed ? 'inset 0 2px 4px rgba(16,20,24,0.08)' : (hover ? 'var(--shadow-2)' : 'var(--shadow-1)'),
transform: pressed ? 'translateY(1px)' : (hover ? 'translateY(-2px)' : 'translateY(0)'),
transition: 'transform 120ms ease, box-shadow 120ms ease',
cursor: 'pointer',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
overflow: 'hidden',
display: 'flex', flexDirection: 'column',
outline: 'none',
}}
>
{/* top stripe */}
<div style={{
height: 8,
background: s.accent,
flexShrink: 0,
}} />
<div style={{ padding: '12px 16px 14px', flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Top row: name BIG + status label */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div style={{
fontSize: 38, fontWeight: 700, lineHeight: 1,
letterSpacing: -1,
color: 'var(--ink-900)',
fontFamily: "'Geist Mono', 'Geist', monospace",
}}>{name}</div>
<div style={{
fontSize: 12, fontWeight: 700,
color: s.ink,
textTransform: 'uppercase',
letterSpacing: 0.6,
textAlign: 'right',
lineHeight: 1.2,
paddingTop: 4,
}}>{s.label}</div>
</div>
{/* Flags row — fixed height */}
<div style={{ marginTop: 6, height: 22, display: 'flex', gap: 6, alignItems: 'center' }}>
{flags.map(f => <Flag key={f} kind={f} />)}
</div>
{/* Stats line */}
<div style={{
marginTop: 'auto',
display: 'flex',
alignItems: 'baseline',
gap: 14,
}}>
<div style={{
fontSize: 22, fontWeight: 600,
color: 'var(--ink-900)',
fontFamily: "'Geist Mono', 'Geist', monospace",
}}>
{amount != null ? formatEuro(amount) : <EmptyDash />}
</div>
<div style={{ width: 1, height: 16, background: s.tintStrong }} />
<div style={{
fontSize: 16,
fontFamily: "'Geist Mono', 'Geist', monospace",
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
color: 'var(--ink-700)',
}}>
{occupiedMins != null ? formatDuration(occupiedMins) : <EmptyDash />}
</div>
</div>
{/* Waiter row */}
<div style={{
marginTop: 10,
height: 32,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{waiters.length === 0 ? (
<span style={{ color: 'var(--ink-400)', fontSize: 13 }}>Unassigned</span>
) : showMulti ? (
<>
<div style={{ display: 'flex' }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
<Initials name={w} size={26} />
</div>
))}
</div>
<span style={{
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
background: 'white', border: '1px solid var(--ink-200)',
borderRadius: 999, padding: '3px 10px',
}}>Multiple ({waiters.length})</span>
</>
) : (
waiters.map((w, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Initials name={w} size={26} />
<span style={{ fontSize: 14, color: 'var(--ink-700)', fontWeight: 500 }}>{w.split(' ')[0]}</span>
</div>
))
)}
</div>
</div>
</button>
);
}
// ===========================================================================
// VARIATION 3 — Bold name badge, stats right-aligned
// Name lives in a colored badge in the top-left like a table plaque.
// Good for tablet thumb reach — name is easy to tap to open.
// ===========================================================================
function TableCardV3({ name, status, amount, occupiedMins, waiters = [], flags = [] }) {
const s = STATUS[status];
const [hover, setHover] = React.useState(false);
const [pressed, setPressed] = React.useState(false);
const showMulti = waiters.length >= 3;
return (
<button
type="button"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPressed(false); }}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
style={{
'--cardBg': s.tint,
position: 'relative',
width: 260, height: 180,
padding: 14,
background: s.tint,
border: '1px solid ' + s.tintStrong,
borderRadius: 'var(--radius)',
boxShadow: pressed ? 'inset 0 2px 4px rgba(16,20,24,0.08)' : (hover ? 'var(--shadow-2)' : 'var(--shadow-1)'),
transform: pressed ? 'translateY(1px)' : (hover ? 'translateY(-2px)' : 'translateY(0)'),
transition: 'transform 120ms ease, box-shadow 120ms ease',
cursor: 'pointer',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
display: 'flex', flexDirection: 'column',
outline: 'none',
}}
>
{/* Top row — plaque + status + flags */}
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<div style={{
width: 68, height: 56,
borderRadius: 10,
background: s.accent,
color: 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 26, fontWeight: 700,
letterSpacing: -0.5,
fontFamily: "'Geist Mono', 'Geist', monospace",
flexShrink: 0,
}}>{name}</div>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
fontSize: 13, fontWeight: 700,
color: s.ink,
textTransform: 'uppercase',
letterSpacing: 0.6,
lineHeight: 1,
}}>{s.label}</div>
{/* Flags row — fixed height so layout stays stable */}
<div style={{ height: 22, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap', overflow: 'hidden' }}>
{flags.map(f => <Flag key={f} kind={f} />)}
</div>
</div>
</div>
{/* Stats */}
<div style={{
marginTop: 'auto',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 6,
}}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink-900)', marginTop: 2, fontFamily: "'Geist Mono', 'Geist', monospace", lineHeight: 1.1 }}>
{amount != null ? formatEuro(amount) : <EmptyDash />}
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
<div style={{
fontSize: 22, marginTop: 2,
fontFamily: "'Geist Mono', 'Geist', monospace",
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
color: 'var(--ink-900)',
lineHeight: 1.1,
}}>
{occupiedMins != null ? formatDuration(occupiedMins) : <EmptyDash />}
</div>
</div>
</div>
{/* Waiter row */}
<div style={{
marginTop: 10,
paddingTop: 10,
borderTop: '1px solid ' + s.tintStrong,
height: 38,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{waiters.length === 0 ? (
<span style={{ color: 'var(--ink-400)', fontSize: 13 }}>Unassigned</span>
) : showMulti ? (
<>
<div style={{ display: 'flex' }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
<Initials name={w} size={26} />
</div>
))}
</div>
<span style={{
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
background: 'white', border: '1px solid var(--ink-200)',
borderRadius: 999, padding: '3px 10px',
}}>Multiple ({waiters.length})</span>
</>
) : (
waiters.map((w, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Initials name={w} size={26} />
<span style={{ fontSize: 14, color: 'var(--ink-700)', fontWeight: 500 }}>{w.split(' ')[0]}</span>
</div>
))
)}
</div>
</button>
);
}
// Export to window
Object.assign(window, { TableCardV1, TableCardV2, TableCardV3, STATUS });