From bb39088464c251ec523b4ed90640de7bee626ccc Mon Sep 17 00:00:00 2001 From: bonamin Date: Wed, 29 Apr 2026 12:12:23 +0300 Subject: [PATCH] Frontend overhaul: manager dashboard restructure, waiter PWA rework, new order drawer and components Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE_DESIGN/.design-canvas.state.json | 1 + CLAUDE_DESIGN/Daily Ops Dashboard.html | 82 + CLAUDE_DESIGN/Order Drawer.html | 66 + CLAUDE_DESIGN/Table Cards.html | 81 + CLAUDE_DESIGN/app.jsx | 115 + CLAUDE_DESIGN/browser-window.jsx | 114 + CLAUDE_DESIGN/design-canvas.jsx | 622 +++ CLAUDE_DESIGN/ios-frame.jsx | 338 ++ CLAUDE_DESIGN/menu-data.jsx | 122 + CLAUDE_DESIGN/ops-app.jsx | 51 + CLAUDE_DESIGN/ops-cards.jsx | 199 + CLAUDE_DESIGN/ops-data.jsx | 73 + CLAUDE_DESIGN/ops-layouts.jsx | 217 ++ CLAUDE_DESIGN/ops-shifts.jsx | 283 ++ CLAUDE_DESIGN/ops-ui.jsx | 145 + CLAUDE_DESIGN/order-app.jsx | 185 + CLAUDE_DESIGN/order-drawer.jsx | 920 +++++ CLAUDE_DESIGN/table-card.jsx | 478 +++ manager_dashboard/package-lock.json | 3137 +++++++++++++++ manager_dashboard/src/App.jsx | 19 +- manager_dashboard/src/api/client.js | 7 +- manager_dashboard/src/components/Sidebar.jsx | 11 +- manager_dashboard/src/icons/add.svg | 5 + manager_dashboard/src/icons/delete.svg | 8 + manager_dashboard/src/icons/edit.svg | 4 + manager_dashboard/src/icons/move-down.svg | 5 + manager_dashboard/src/icons/move-up.svg | 5 + manager_dashboard/src/index.css | 2 +- manager_dashboard/src/layouts/AppLayout.jsx | 197 +- manager_dashboard/src/pages/DashboardPage.jsx | 320 -- manager_dashboard/src/pages/DashboardTab.jsx | 739 ++++ .../src/pages/ManagementPage.jsx | 57 + .../src/pages/OperationsPage.jsx | 1610 ++++++++ .../src/pages/OrderDetailPage.jsx | 12 +- .../{ProductsPage.jsx => ProductsTab.jsx} | 834 +++- manager_dashboard/src/pages/ReportsPage.jsx | 343 +- .../src/pages/Settings/SettingsPage.jsx | 51 + .../src/pages/Settings/tabs/AppInfoTab.jsx | 541 +++ .../src/pages/Settings/tabs/ColoursTab.jsx | 511 +++ manager_dashboard/src/pages/SettingsPage.jsx | 113 - .../pages/{WaitersPage.jsx => StaffTab.jsx} | 127 +- .../src/pages/TablesConfigTab.jsx | 356 ++ manager_dashboard/src/pages/TablesPage.jsx | 1098 ++++-- manager_dashboard/src/store/authStore.js | 24 +- .../src/store/tableColourStore.js | 94 + sysadmin_panel/package-lock.json | 3080 +++++++++++++++ waiter_pwa/README.md | 16 + waiter_pwa/dev-dist/registerSW.js | 1 + waiter_pwa/dev-dist/sw.js | 92 + waiter_pwa/dev-dist/workbox-5a5d9309.js | 3395 +++++++++++++++++ waiter_pwa/eslint.config.js | 29 + waiter_pwa/index.html | 2 +- waiter_pwa/src/App.jsx | 299 +- waiter_pwa/src/assets/icons/backspace.svg | 5 + waiter_pwa/src/assets/icons/categories.svg | 7 + waiter_pwa/src/assets/icons/categories2.svg | 7 + waiter_pwa/src/assets/icons/flags.svg | 4 + waiter_pwa/src/assets/icons/merge.svg | 2 + waiter_pwa/src/assets/icons/notifications.svg | 5 + waiter_pwa/src/assets/icons/print.svg | 6 + waiter_pwa/src/assets/icons/transfer.svg | 3 + waiter_pwa/src/assets/icons/waiter.svg | 13 + waiter_pwa/src/components/Icons.jsx | 46 + waiter_pwa/src/components/OrderDrawer.jsx | 871 +++++ waiter_pwa/src/components/OrderSummary.jsx | 77 +- waiter_pwa/src/components/PinPad.jsx | 7 +- waiter_pwa/src/components/ProductPicker.jsx | 261 +- waiter_pwa/src/components/TableCard.jsx | 204 +- waiter_pwa/src/components/UserMenu.jsx | 181 + .../src/context/NotificationContext.jsx | 123 + waiter_pwa/src/index.css | 408 +- waiter_pwa/src/pages/AddItemsPage.jsx | 572 ++- waiter_pwa/src/pages/LoginPage.jsx | 195 +- waiter_pwa/src/pages/TableDetailPage.jsx | 1041 ++++- waiter_pwa/src/pages/TableListPage.jsx | 331 +- waiter_pwa/src/store/shiftStore.js | 21 + waiter_pwa/src/store/tableColourStore.js | 90 + waiter_pwa/src/store/themeStore.js | 12 + 78 files changed, 24370 insertions(+), 1358 deletions(-) create mode 100644 CLAUDE_DESIGN/.design-canvas.state.json create mode 100644 CLAUDE_DESIGN/Daily Ops Dashboard.html create mode 100644 CLAUDE_DESIGN/Order Drawer.html create mode 100644 CLAUDE_DESIGN/Table Cards.html create mode 100644 CLAUDE_DESIGN/app.jsx create mode 100644 CLAUDE_DESIGN/browser-window.jsx create mode 100644 CLAUDE_DESIGN/design-canvas.jsx create mode 100644 CLAUDE_DESIGN/ios-frame.jsx create mode 100644 CLAUDE_DESIGN/menu-data.jsx create mode 100644 CLAUDE_DESIGN/ops-app.jsx create mode 100644 CLAUDE_DESIGN/ops-cards.jsx create mode 100644 CLAUDE_DESIGN/ops-data.jsx create mode 100644 CLAUDE_DESIGN/ops-layouts.jsx create mode 100644 CLAUDE_DESIGN/ops-shifts.jsx create mode 100644 CLAUDE_DESIGN/ops-ui.jsx create mode 100644 CLAUDE_DESIGN/order-app.jsx create mode 100644 CLAUDE_DESIGN/order-drawer.jsx create mode 100644 CLAUDE_DESIGN/table-card.jsx create mode 100644 manager_dashboard/package-lock.json create mode 100644 manager_dashboard/src/icons/add.svg create mode 100644 manager_dashboard/src/icons/delete.svg create mode 100644 manager_dashboard/src/icons/edit.svg create mode 100644 manager_dashboard/src/icons/move-down.svg create mode 100644 manager_dashboard/src/icons/move-up.svg delete mode 100644 manager_dashboard/src/pages/DashboardPage.jsx create mode 100644 manager_dashboard/src/pages/DashboardTab.jsx create mode 100644 manager_dashboard/src/pages/ManagementPage.jsx create mode 100644 manager_dashboard/src/pages/OperationsPage.jsx rename manager_dashboard/src/pages/{ProductsPage.jsx => ProductsTab.jsx} (51%) create mode 100644 manager_dashboard/src/pages/Settings/SettingsPage.jsx create mode 100644 manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx create mode 100644 manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx delete mode 100644 manager_dashboard/src/pages/SettingsPage.jsx rename manager_dashboard/src/pages/{WaitersPage.jsx => StaffTab.jsx} (76%) create mode 100644 manager_dashboard/src/pages/TablesConfigTab.jsx create mode 100644 manager_dashboard/src/store/tableColourStore.js create mode 100644 sysadmin_panel/package-lock.json create mode 100644 waiter_pwa/README.md create mode 100644 waiter_pwa/dev-dist/registerSW.js create mode 100644 waiter_pwa/dev-dist/sw.js create mode 100644 waiter_pwa/dev-dist/workbox-5a5d9309.js create mode 100644 waiter_pwa/eslint.config.js create mode 100644 waiter_pwa/src/assets/icons/backspace.svg create mode 100644 waiter_pwa/src/assets/icons/categories.svg create mode 100644 waiter_pwa/src/assets/icons/categories2.svg create mode 100644 waiter_pwa/src/assets/icons/flags.svg create mode 100644 waiter_pwa/src/assets/icons/merge.svg create mode 100644 waiter_pwa/src/assets/icons/notifications.svg create mode 100644 waiter_pwa/src/assets/icons/print.svg create mode 100644 waiter_pwa/src/assets/icons/transfer.svg create mode 100644 waiter_pwa/src/assets/icons/waiter.svg create mode 100644 waiter_pwa/src/components/Icons.jsx create mode 100644 waiter_pwa/src/components/OrderDrawer.jsx create mode 100644 waiter_pwa/src/components/UserMenu.jsx create mode 100644 waiter_pwa/src/context/NotificationContext.jsx create mode 100644 waiter_pwa/src/store/shiftStore.js create mode 100644 waiter_pwa/src/store/tableColourStore.js create mode 100644 waiter_pwa/src/store/themeStore.js diff --git a/CLAUDE_DESIGN/.design-canvas.state.json b/CLAUDE_DESIGN/.design-canvas.state.json new file mode 100644 index 0000000..6b13e20 --- /dev/null +++ b/CLAUDE_DESIGN/.design-canvas.state.json @@ -0,0 +1 @@ +{"sections":{"v1":{"labels":{"v1-grid":"Grid of 8 tables — mixed statuses"}}}} \ No newline at end of file diff --git a/CLAUDE_DESIGN/Daily Ops Dashboard.html b/CLAUDE_DESIGN/Daily Ops Dashboard.html new file mode 100644 index 0000000..47b12f2 --- /dev/null +++ b/CLAUDE_DESIGN/Daily Ops Dashboard.html @@ -0,0 +1,82 @@ + + + + + +Daily Ops — SimplePOS + + + + + + +
+ + + + + + + + + + + + + + + diff --git a/CLAUDE_DESIGN/Order Drawer.html b/CLAUDE_DESIGN/Order Drawer.html new file mode 100644 index 0000000..a2b7cd5 --- /dev/null +++ b/CLAUDE_DESIGN/Order Drawer.html @@ -0,0 +1,66 @@ + + + + + +Order Drawer — SimplePOS + + + + + + +
+ + + + + + + + + + + diff --git a/CLAUDE_DESIGN/Table Cards.html b/CLAUDE_DESIGN/Table Cards.html new file mode 100644 index 0000000..c02a5d7 --- /dev/null +++ b/CLAUDE_DESIGN/Table Cards.html @@ -0,0 +1,81 @@ + + + + + +Table Cards — SimplePOS + + + + + + +
+ + + + + + + + + + diff --git a/CLAUDE_DESIGN/app.jsx b/CLAUDE_DESIGN/app.jsx new file mode 100644 index 0000000..6eee937 --- /dev/null +++ b/CLAUDE_DESIGN/app.jsx @@ -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 ( +
+ {TABLES.map((t, i) => )} +
+ ); +} + +function StatesStrip({ Card, label }) { + // Render four copies with simulated states by forcing CSS + return ( +
+
+ {['Default', 'Hover', 'Pressed', 'Selected'].map((state) => ( +
+
{state}
+
+ + {state === 'Selected' && ( +
+ )} +
+
+ ))} +
+ + {/* Style overrides to force hover/pressed on the middle two */} + +
+ ); +} + +function App() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); diff --git a/CLAUDE_DESIGN/browser-window.jsx b/CLAUDE_DESIGN/browser-window.jsx new file mode 100644 index 0000000..b90e273 --- /dev/null +++ b/CLAUDE_DESIGN/browser-window.jsx @@ -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 ( +
+
+
+
+
+ ); +} + +// Single tab (active has curved scoops) +function ChromeTab({ title = 'New Tab', active = false }) { + const curve = (flip) => ( + + + + ); + return ( +
+ {active && curve(false)} + {active && curve(true)} +
+ {title} +
+ ); +} + +function ChromeTabBar({ tabs = [{ title: 'New Tab' }], activeIndex = 0 }) { + return ( +
+ +
+ {tabs.map((t, i) => )} +
+
+ ); +} + +function ChromeToolbar({ url = 'example.com' }) { + const iconDot = ( +
+
+
+ ); + return ( +
+ {iconDot} + {/* url bar */} +
+
+ {url} +
+ {iconDot} +
+ ); +} + +function ChromeWindow({ + tabs = [{ title: 'New Tab' }], activeIndex = 0, url = 'example.com', + width = 900, height = 600, children, +}) { + return ( +
+ + +
+ {children} +
+
+ ); +} + +Object.assign(window, { + ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights, +}); diff --git a/CLAUDE_DESIGN/design-canvas.jsx b/CLAUDE_DESIGN/design-canvas.jsx new file mode 100644 index 0000000..9f3fc61 --- /dev/null +++ b/CLAUDE_DESIGN/design-canvas.jsx @@ -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: +// +// +// +// +// +// + +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 ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// 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 ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// 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 ( +
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// 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 ( +
+
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+ +
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// 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 }) => ( + + ); + + // 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( +
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) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/CLAUDE_DESIGN/ios-frame.jsx b/CLAUDE_DESIGN/ios-frame.jsx new file mode 100644 index 0000000..1a15e27 --- /dev/null +++ b/CLAUDE_DESIGN/ios-frame.jsx @@ -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 ( +
+
+ {time} +
+
+ + + + + + + + + + + + + + + + +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Liquid glass pill — blur + tint + shine +// ───────────────────────────────────────────────────────────── +function IOSGlassPill({ children, dark = false, style = {} }) { + return ( +
+ {/* blur + tint */} +
+ {/* shine */} +
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// 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) => ( + +
+ {content} +
+
+ ); + return ( +
+
+ {/* back chevron */} + {pillIcon( + + + + )} + {/* trailing ellipsis */} + {trailingIcon && pillIcon( + + + + + + )} +
+ {/* large title */} +
{title}
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// 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 ( +
+ {icon && ( +
+ )} +
{title}
+ {detail && {detail}} + {chevron && ( + + + + )} + {!isLast && ( +
+ )} +
+ ); +} + +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 ( +
+ {header && ( +
{header}
+ )} +
{children}
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Device frame +// ───────────────────────────────────────────────────────────── +function IOSDevice({ + children, width = 402, height = 874, dark = false, + title, keyboard = false, +}) { + return ( +
+ {/* dynamic island */} +
+ {/* status bar (absolute) */} +
+ +
+ {/* nav + content */} +
+ {title !== undefined && } +
{children}
+ {keyboard && } +
+ {/* home indicator — always on top */} +
+
+
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// 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: , + del: , + ret: , + }; + + const key = (content, { w, flex, ret, fs = 25, k } = {}) => ( +
{content}
+ ); + + const row = (keys, pad = 0) => ( +
+ {keys.map(l => key(l, { flex: true, k: l }))} +
+ ); + + return ( +
+ {/* liquid glass bg — same recipe as nav pills */} +
+
+ + {/* autocorrect bar */} +
+ {['"The"', 'the', 'to'].map((w, i) => ( + + {i > 0 &&
} +
{w}
+ + ))} +
+ + {/* key layout */} +
+ {row(['q','w','e','r','t','y','u','i','o','p'])} + {row(['a','s','d','f','g','h','j','k','l'], 20)} +
+ {key(icons.shift, { w: 45, k: 'shift' })} +
+ {['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))} +
+ {key(icons.del, { w: 45, k: 'del' })} +
+
+ {key('ABC', { w: 92.25, fs: 18, k: 'abc' })} + {key('', { flex: true, k: 'space' })} + {key(icons.ret, { w: 92.25, ret: true, k: 'ret' })} +
+
+ + {/* bottom spacer (emoji+mic area, icons omitted) */} +
+
+ ); +} + +Object.assign(window, { + IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard, +}); diff --git a/CLAUDE_DESIGN/menu-data.jsx b/CLAUDE_DESIGN/menu-data.jsx new file mode 100644 index 0000000..106a9cc --- /dev/null +++ b/CLAUDE_DESIGN/menu-data.jsx @@ -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; diff --git a/CLAUDE_DESIGN/ops-app.jsx b/CLAUDE_DESIGN/ops-app.jsx new file mode 100644 index 0000000..c137be0 --- /dev/null +++ b/CLAUDE_DESIGN/ops-app.jsx @@ -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 ( +
+
{children}
+
+ ); +} + +function App() { + return ( + + + + + + + + + + + +
+ + + +
+
+
+
+ ); +} + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); diff --git a/CLAUDE_DESIGN/ops-cards.jsx b/CLAUDE_DESIGN/ops-cards.jsx new file mode 100644 index 0000000..1f90b9c --- /dev/null +++ b/CLAUDE_DESIGN/ops-cards.jsx @@ -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 ( +
+
+
{label}
+ +
+
{value}
+ {sub &&
{sub}
} + {children} +
+ ); +} + +// Progress bar used inside KPI cards +function ProgressBar({ pct, color = 'var(--brand-500)' }) { + return ( +
+
+
+ ); +} + +// ---------------------------------------------------------------- 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 ( + View floor}> +
+ {Object.entries(states).map(([name, status]) => { + const c = colors[status]; + return ( +
{name}
+ ); + })} +
+
+ {[ + ['occupied', 'Occupied'], + ['open', 'Open'], + ['reserved', 'Reserved'], + ['alert', 'Alert'], + ['dirty', 'Cleaning'], + ].map(([k, label]) => ( +
+ + {label} + {counts[k] || 0} +
+ ))} +
+
+ ); +} + +// ---------------------------------------------------------------- Hourly chart +function HourlyRevenueCard() { + const data = OPS_DATA.hourly; + const max = Math.max(...data.map(d => d.revenue), 800); + const currentHour = '19'; + return ( + Today
}> +
+ {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 ( +
+
+
0 ? 4 : 0, + background: isCurrent + ? 'var(--brand-500)' + : isFuture + ? 'var(--ink-100)' + : 'var(--brand-200)', + }} /> +
+
{d.hour}
+
+ ); + })} +
+ + ); +} + +// ---------------------------------------------------------------- Reservations +function ReservationsCard() { + return ( + + Add}> +
+ {OPS_DATA.reservations.map(r => ( +
+
{r.time}
+
+
{r.name}
+
+ {r.guests} guests · Table {r.table} + {r.notes && <> · {r.notes}} +
+
+
+ ))} +
+
+ ); +} + +window.OpsCards = { KpiCard, ProgressBar, TablesOverview, HourlyRevenueCard, ReservationsCard }; diff --git a/CLAUDE_DESIGN/ops-data.jsx b/CLAUDE_DESIGN/ops-data.jsx new file mode 100644 index 0000000..a5d3867 --- /dev/null +++ b/CLAUDE_DESIGN/ops-data.jsx @@ -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', + ], +}; diff --git a/CLAUDE_DESIGN/ops-layouts.jsx b/CLAUDE_DESIGN/ops-layouts.jsx new file mode 100644 index 0000000..dbfa064 --- /dev/null +++ b/CLAUDE_DESIGN/ops-layouts.jsx @@ -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 ( +
+
TS
+
+
+
{b.name}
+ + + Day open + +
+
+ {b.date} · started at {b.dayStartedAt} · {Math.floor(b.dayDurationMins/60)}h {b.dayDurationMins%60}m running +
+
+
+ + Reports + End day +
+
+ ); +} + +// Compact tablet top bar +function TabletTopBar({ onEndDay }) { + const b = OPS_DATA.business; + return ( +
+
+
+
{b.name}
+ + + Open + +
+
+ {b.date} · {b.dayStartedAt} · {Math.floor(b.dayDurationMins/60)}h {b.dayDurationMins%60}m +
+
+ + End day +
+ ); +} + +// 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 ( +
+ + + + + + + + + +
+ ); +} + +// ---------------------------------------------------------------- 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 ( +
+ setEndDayOpen(true)} /> +
+
+
Today at a glance
+
Updated 19:08 · auto-refresh
+
+ + + +
+
+ + openCompose('', s.name)} /> +
+
+ + + +
+
+
+ + setComposeOpen(false)} /> + setEndDayOpen(false)} onConfirm={() => setEndDayOpen(false)} /> +
+ ); +} + +// ---------------------------------------------------------------- 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 ( +
+ setEndDayOpen(true)} /> +
+ +
+ + + openCompose('', s.name)} /> + + +
+
+ setComposeOpen(false)} /> + setEndDayOpen(false)} onConfirm={() => setEndDayOpen(false)} /> +
+ ); +} + +window.OpsLayouts = { DesktopDashboard, TabletDashboard }; diff --git a/CLAUDE_DESIGN/ops-shifts.jsx b/CLAUDE_DESIGN/ops-shifts.jsx new file mode 100644 index 0000000..ce80400 --- /dev/null +++ b/CLAUDE_DESIGN/ops-shifts.jsx @@ -0,0 +1,283 @@ +// Shifts card + Messages card + +const { Avatar, Card, Btn, Icon } = window.OpsUI; +const { OPS_DATA } = window; + +// ---------------------------------------------------------------- Shifts +function ShiftsCard({ onMessage }) { + return ( + + Start shift} + > +
+ {OPS_DATA.shifts.map(s => ( +
+ +
+
+
{s.name}
+ {s.status === 'break' && ( + On break + )} +
+
+ {s.section} · in at {s.clockIn} · {s.hoursWorked.toFixed(1)}h worked + {s.tables.length > 0 && <> · tables {s.tables.join(', ')}} +
+
+
+ + +
+
+ ))} + + {/* scheduled / not yet started */} +
Scheduled later
+ {OPS_DATA.scheduledShifts.map(s => ( +
+ +
+
{s.name}
+
+ {s.section} · starts at {s.scheduledAt} +
+
+ Start now +
+ ))} +
+
+ ); +} + +// ---------------------------------------------------------------- Messages +function MessagesCard({ openCompose }) { + return ( + openCompose()}> New} + > + {/* Quick presets */} +
Quick send
+
+ {OPS_DATA.presets.map(p => ( + + ))} +
+ +
Recent
+
+ {OPS_DATA.recentMessages.map(m => ( +
+ +
+
+ {m.to} + · {m.text} +
+
+
{m.sentAt}
+ {!m.read &&
} +
+ ))} +
+ + ); +} + +// ---------------------------------------------------------------- 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 ( +
+
e.stopPropagation()} style={{ + width: 'min(520px, 100%)', + background: 'white', + borderRadius: 18, + padding: 24, + boxShadow: '0 20px 60px rgba(0,0,0,0.25)', + }}> +
+
New message
+ +
+ +
To
+
+ {recipients.map(r => ( + + ))} +
+ +
Message
+