@theatrical/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +663 -198
  2. package/package.json +5 -3
package/dist/index.js CHANGED
@@ -137,9 +137,657 @@ function stripUndefined(obj) {
137
137
  import * as fs2 from "fs";
138
138
  import * as path2 from "path";
139
139
  import { Command } from "commander";
140
+
141
+ // src/templates/react-ticketing.generated.ts
142
+ var REACT_TICKETING_FILES = [
143
+ {
144
+ "path": ".env.example",
145
+ "content": "# Theatrical React Ticketing Template\n#\n# This demo runs entirely self-contained \u2014 a simulated living cinema\n# observed by the real @theatrical/events watchers. No API key, no\n# environment configuration required.\n#\n# When pointing the watchers at a real cinema platform API, configure\n# your client credentials here:\n# VITE_THEATRICAL_API_KEY=your_api_key_here\n"
146
+ },
147
+ {
148
+ "path": ".gitignore",
149
+ "content": "node_modules\ndist\n.env\n*.log\n.vercel\n"
150
+ },
151
+ {
152
+ "path": "index.html",
153
+ "content": `<!DOCTYPE html>
154
+ <html lang="en">
155
+ <head>
156
+ <meta charset="UTF-8" />
157
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
158
+ <title>Theatrical \u2014 Cinema Ticketing</title>
159
+ <meta name="description" content="Cinema ticketing powered by @theatrical/sdk" />
160
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
161
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
162
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
163
+ <style>
164
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
165
+ body { background: #F0EDE6; color: #1A1A1A; font-family: 'Inter', system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; }
166
+ </style>
167
+ </head>
168
+ <body>
169
+ <div id="root"></div>
170
+ <script type="module" src="/src/main.tsx"></script>
171
+ </body>
172
+ </html>
173
+ `
174
+ },
175
+ {
176
+ "path": "package.json",
177
+ "content": '{\n "name": "{{PROJECT_NAME}}",\n "private": true,\n "version": "0.1.0",\n "description": "React cinema ticketing starter \u2014 full booking flow using @theatrical/sdk and @theatrical/react",\n "type": "module",\n "scripts": {\n "dev": "vite",\n "build": "tsc --noEmit && vite build",\n "preview": "vite preview"\n },\n "dependencies": {\n "@theatrical/react": "^0.1.0",\n "@theatrical/sdk": "^0.1.0",\n "react": "^18.2.0",\n "react-dom": "^18.2.0",\n "react-router-dom": "^6.22.0",\n "@theatrical/events": "^0.1.0",\n "framer-motion": "^11.0.0"\n },\n "devDependencies": {\n "@theatrical/cli": "^0.1.0",\n "@types/react": "^18.2.0",\n "@types/react-dom": "^18.2.0",\n "typescript": "^5.4.0",\n "vite": "^5.2.0",\n "@vitejs/plugin-react": "^4.2.0"\n }\n}\n'
178
+ },
179
+ {
180
+ "path": "README.md",
181
+ "content": "# react-ticketing\n\nA living cinema booking demo built on `@theatrical/events`.\n\nA request-response API hands you a photograph. This app runs a cinema whose state genuinely changes \u2014 seats sell, sessions sell out, orders confirm \u2014 and the real `BookingWatcher` and `SessionWatcher` from the published `@theatrical/events` package poll it, diff it, and emit the typed event stream that drives the UI. The interface listens to the pulse, not the photo.\n\nThree pages: programme \u2192 seat selection \u2192 confirmation.\n\n## Quick Start\n\n```bash\n# Scaffold with the CLI\nnpx @theatrical/cli init my-cinema --template react-ticketing\ncd my-cinema\n\n# Install and run \u2014 no API key, no configuration\nnpm install\nnpm run dev\n```\n\nOpen [http://localhost:5173](http://localhost:5173).\n\nOr run it from the monorepo:\n\n```bash\ngit clone https://github.com/brunohart/theatrical.git\ncd theatrical/packages/templates/react-ticketing\nnpm install && npm run dev\n```\n\n## Architecture\n\n```\nsrc/\n\u251C\u2500\u2500 lib/cinema.ts # The living cinema \u2014 simulated state + real\n\u2502 # @theatrical/events watchers (poll \u2192 diff \u2192 emit)\n\u251C\u2500\u2500 pages/\n\u2502 \u251C\u2500\u2500 Seats.tsx # Seat map + selection for a session\n\u2502 \u2514\u2500\u2500 Confirmation.tsx # Booking confirmation\n\u251C\u2500\u2500 components/ # Chrome, Poster, Timeboard, MissionControl, CodeSeam\n\u251C\u2500\u2500 App.tsx # Router + Home programme grid\n\u2514\u2500\u2500 theme.ts # Design tokens\n```\n\nRoutes: `/` (programme) \u2192 `/book/:sessionId` (seats) \u2192 `/done` (confirmation).\n\n## What it demonstrates\n\n| Capability | Package |\n|------------|---------|\n| `BookingWatcher` \u2014 typed `booking.confirmed` events from polled order state | `@theatrical/events` |\n| `SessionWatcher` \u2014 `session.soldout` detection via state diffing | `@theatrical/events` |\n| Event-driven UI \u2014 the timeboard and mission control react to the stream | \u2014 |\n\nThe watchers are the published package, unmodified \u2014 the same poll \u2192 diff \u2192 emit\npipeline you would point at a real cinema platform API.\n\n## Deploy to Vercel\n\n[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/brunohart/theatrical/tree/main/packages/templates/react-ticketing)\n\nOr via CLI:\n\n```bash\ncd packages/templates/react-ticketing\nnpx vercel --prod\n```\n"
182
+ },
183
+ {
184
+ "path": "src/App.tsx",
185
+ "content": `/// <reference types="vite/client" />
186
+ import React, { useEffect, useState } from 'react';
187
+ import { BrowserRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
188
+ import { motion, AnimatePresence } from 'framer-motion';
189
+ import { T } from './theme';
190
+ import { Chrome } from './components/Chrome';
191
+ import { Timeboard } from './components/Timeboard';
192
+ import { MissionControl } from './components/MissionControl';
193
+ import { CodeSeam } from './components/CodeSeam';
194
+ import { SeatsPage } from './pages/Seats';
195
+ import { ConfirmationPage } from './pages/Confirmation';
196
+ import { usePulse, filmOf, SCREENS, type PulseState } from './lib/cinema';
197
+ import { useIsMobile } from './lib/responsive';
198
+
199
+ type Lens = 'audience' | 'operator' | 'developer';
200
+ const LENSES: { id: Lens; label: string; desc: string }[] = [
201
+ { id: 'audience', label: 'Audience', desc: 'What a moviegoer sees \u2014 browse the live board and book.' },
202
+ { id: 'operator', label: 'Operator', desc: 'What a cinema manager sees \u2014 the building\u2019s live pulse.' },
203
+ { id: 'developer', label: 'Developer', desc: 'What you ship \u2014 the whole thing in a few lines of @theatrical.' },
204
+ ];
205
+
206
+ function Nav() {
207
+ const now = new Date().toLocaleTimeString('en-NZ', { hour: '2-digit', minute: '2-digit', hour12: false });
208
+ return (
209
+ <nav style={{ position: 'sticky', top: 'clamp(14px,2.4vw,26px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: 60, padding: '0 clamp(20px,5vw,40px)', borderBottom: \`1px solid \${T.border}\`, background: 'rgba(240,237,230,0.82)', backdropFilter: 'blur(10px)' }}>
210
+ <a href="/" data-hot style={{ display: 'inline-flex', alignItems: 'center', gap: 9, textDecoration: 'none' }}>
211
+ <svg width="20" height="20" viewBox="0 0 32 32" style={{ display: 'block', flexShrink: 0 }} aria-hidden="true">
212
+ <rect width="32" height="32" rx="6" fill={T.navy} />
213
+ <rect x="8" y="8" width="16" height="16" rx="3" fill={T.orange} />
214
+ <rect x="13" y="13" width="6" height="6" rx="1.5" fill={T.bg} />
215
+ </svg>
216
+ <span style={{ fontFamily: T.display, fontWeight: 700, fontSize: 18, letterSpacing: '-0.03em', color: T.navy }}>theatrical</span>
217
+ <span style={{ fontFamily: T.mono, fontSize: 9, color: T.muted, background: T.surfaceRaised, border: \`1px solid \${T.border}\`, padding: '2px 6px', borderRadius: 4, letterSpacing: '0.1em' }}>LIVE DEMO</span>
218
+ </a>
219
+ <span style={{ fontFamily: T.mono, fontSize: 12, color: T.muted, display: 'inline-flex', alignItems: 'center', gap: 8 }}>
220
+ <span style={{ width: 6, height: 6, borderRadius: '50%', background: T.green }} /> Roxy \xB7 Wellington \xB7 {now}
221
+ </span>
222
+ </nav>
223
+ );
224
+ }
225
+
226
+ function LensBar({ lens, setLens, onTour }: { lens: Lens; setLens: (l: Lens) => void; onTour: () => void }) {
227
+ const navigate = useNavigate();
228
+ const isMobile = useIsMobile();
229
+ const active = LENSES.find((l) => l.id === lens)!;
230
+ return (
231
+ <div style={{ position: 'sticky', top: 'calc(60px + clamp(14px,2.4vw,26px))', zIndex: 90, background: 'rgba(240,237,230,0.92)', backdropFilter: 'blur(8px)', borderBottom: \`1px solid \${T.border}\` }}>
232
+ <div style={{ maxWidth: 1180, margin: '0 auto', padding: isMobile ? '10px clamp(20px,5vw,40px)' : '12px clamp(20px,5vw,40px)', display: 'flex', alignItems: 'center', gap: isMobile ? 8 : 16, flexWrap: 'wrap' }}>
233
+ {!isMobile && <span style={{ fontFamily: T.mono, fontSize: 11, color: T.muted, letterSpacing: '0.06em' }}>One cinema, live \xB7 seen as</span>}
234
+ <div style={{ display: 'flex', flex: isMobile ? 1 : 'none', background: T.surface, border: \`1px solid \${T.border}\`, borderRadius: 99, padding: 3 }}>
235
+ {LENSES.map((l) => {
236
+ const on = l.id === lens;
237
+ return (
238
+ <button key={l.id} data-hot onClick={() => { setLens(l.id); navigate('/'); }}
239
+ style={{ position: 'relative', flex: isMobile ? 1 : 'none', border: 'none', background: 'none', cursor: 'pointer', padding: isMobile ? '8px 10px' : '7px 16px', borderRadius: 99, fontFamily: T.body, fontSize: 13.5, fontWeight: 600 }}>
240
+ {on && <motion.span layoutId="lenspill" transition={T.spring} style={{ position: 'absolute', inset: 0, background: T.orange, borderRadius: 99, zIndex: 0 }} />}
241
+ <span style={{ position: 'relative', zIndex: 1, color: on ? T.white : T.inkSoft }}>{l.label}</span>
242
+ </button>
243
+ );
244
+ })}
245
+ </div>
246
+ {!isMobile && <span style={{ fontSize: 13, color: T.muted, flex: 1, minWidth: 180 }}>{active.desc}</span>}
247
+ <button data-hot onClick={onTour} aria-label="Auto-tour"
248
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: \`1px solid \${T.navy}\`, background: 'none', color: T.navy, cursor: 'pointer', padding: isMobile ? '8px 12px' : '7px 14px', borderRadius: 99, fontFamily: T.body, fontSize: 13, fontWeight: 600, flexShrink: 0 }}>
249
+ \u25B6{isMobile ? '' : ' Auto-tour'}
250
+ </button>
251
+ {isMobile && <span style={{ flexBasis: '100%', fontSize: 12.5, color: T.muted, lineHeight: 1.35 }}>{active.desc}</span>}
252
+ </div>
253
+ </div>
254
+ );
255
+ }
256
+
257
+ function Home({ lens, pulse }: { lens: Lens; pulse: PulseState }) {
258
+ const navigate = useNavigate();
259
+ if (lens === 'operator') return <MissionControl pulse={pulse} />;
260
+ if (lens === 'developer') return <CodeSeam />;
261
+ return <Timeboard pulse={pulse} onOpen={(id) => navigate(\`/book/\${id}\`)} />;
262
+ }
263
+
264
+ interface TourStep { caption: string; ms: number; run: (ctx: { pulse: PulseState; nav: ReturnType<typeof useNavigate>; setLens: (l: Lens) => void }) => void; }
265
+ const TOUR: TourStep[] = [
266
+ { caption: 'One cinema, breathing live. This is what the audience sees \u2014 every seat filling in real time.', ms: 5600, run: ({ setLens, nav }) => { setLens('audience'); nav('/'); } },
267
+ { caption: 'Step inside \u2014 a centred, per-screen auditorium. Seats keep filling as you choose.', ms: 5600, run: ({ pulse, nav }) => { const s = pulse.sessions.find((x) => !x.soldOut) ?? pulse.sessions[0]!; nav(\`/book/\${s.id}\`); } },
268
+ { caption: 'Booked \u2014 a real ticket, in a few taps.', ms: 4600, run: ({ pulse, nav }) => { const s = pulse.sessions.find((x) => !x.soldOut) ?? pulse.sessions[0]!; const f = filmOf(s.filmId); nav('/done', { state: { filmId: f.id, filmTitle: f.title, screen: SCREENS[s.screenId]!.name, format: SCREENS[s.screenId]!.format, time: s.startTime, seats: ['F7', 'F8', 'F9'], price: s.priceFrom } }); } },
269
+ { caption: 'The operator\u2019s view: the pulse surfaced \u2014 sell-outs projected before they happen.', ms: 6200, run: ({ setLens, nav }) => { setLens('operator'); nav('/'); } },
270
+ { caption: 'And the whole living system? A few lines of @theatrical. Build it this afternoon.', ms: 6400, run: ({ setLens, nav }) => { setLens('developer'); nav('/'); } },
271
+ ];
272
+
273
+ function readLens(): Lens {
274
+ const l = new URLSearchParams(window.location.search).get('lens');
275
+ return (['audience', 'operator', 'developer'] as const).includes(l as Lens) ? (l as Lens) : 'audience';
276
+ }
277
+
278
+ function Shell({ pulse }: { pulse: PulseState }) {
279
+ const [lens, setLens] = useState<Lens>(readLens);
280
+ const [tour, setTour] = useState<number | null>(null);
281
+ const [nudge, setNudge] = useState(false);
282
+ const navigate = useNavigate();
283
+ const onHome = useLocation().pathname === '/';
284
+
285
+ // Deep links: ?lens=operator lands on a lens; ?tour=1 auto-plays the tour.
286
+ // Otherwise, a subtle one-time nudge invites the tour.
287
+ useEffect(() => {
288
+ const sp = new URLSearchParams(window.location.search);
289
+ if (sp.get('tour') === '1') { setTour(0); return; }
290
+ if (!sessionStorage.getItem('th_tour_seen')) {
291
+ const id = window.setTimeout(() => setNudge(true), 1800);
292
+ return () => clearTimeout(id);
293
+ }
294
+ }, []);
295
+
296
+ const startTour = () => { sessionStorage.setItem('th_tour_seen', '1'); setNudge(false); setTour(0); };
297
+ const dismissNudge = () => { sessionStorage.setItem('th_tour_seen', '1'); setNudge(false); };
298
+
299
+ useEffect(() => {
300
+ if (tour === null) return;
301
+ if (tour >= TOUR.length) { setTour(null); setLens('audience'); navigate('/'); return; }
302
+ TOUR[tour]!.run({ pulse, nav: navigate, setLens });
303
+ const id = window.setTimeout(() => setTour((t) => (t === null ? null : t + 1)), TOUR[tour]!.ms);
304
+ return () => clearTimeout(id);
305
+ // eslint-disable-next-line react-hooks/exhaustive-deps
306
+ }, [tour]);
307
+
308
+ return (
309
+ <div style={{ minHeight: '100vh', background: T.bg, color: T.ink, position: 'relative', paddingInline: 'clamp(0px,2vw,26px)' }}>
310
+ <Chrome />
311
+ <Nav />
312
+ {onHome && <LensBar lens={lens} setLens={setLens} onTour={startTour} />}
313
+ <Routes>
314
+ <Route path="/" element={<Home lens={lens} pulse={pulse} />} />
315
+ <Route path="/book/:sessionId" element={<SeatsPage pulse={pulse} />} />
316
+ <Route path="/done" element={<ConfirmationPage />} />
317
+ </Routes>
318
+ <TourCaption tour={tour} stop={() => { setTour(null); setLens('audience'); navigate('/'); }} />
319
+ <TourNudge show={nudge && tour === null} start={startTour} dismiss={dismissNudge} />
320
+ </div>
321
+ );
322
+ }
323
+
324
+ function TourNudge({ show, start, dismiss }: { show: boolean; start: () => void; dismiss: () => void }) {
325
+ return (
326
+ <AnimatePresence>
327
+ {show && (
328
+ <motion.div initial={{ opacity: 0, y: 16, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 16 }} transition={T.spring}
329
+ style={{ position: 'fixed', left: 'clamp(20px,4vw,40px)', bottom: 'clamp(24px,4vw,40px)', maxWidth: 'calc(100vw - 2 * clamp(20px,4vw,40px))', zIndex: 480, display: 'flex', alignItems: 'center', gap: 12, background: T.surfaceRaised, border: \`1px solid \${T.border}\`, borderRadius: 99, padding: '8px 10px 8px 16px', boxShadow: '0 22px 44px -28px rgba(27,45,79,0.5)' }}>
330
+ <motion.span animate={{ opacity: [1, 0.3, 1] }} transition={{ duration: 1.6, repeat: Infinity }} style={{ width: 7, height: 7, borderRadius: '50%', background: T.orange, flexShrink: 0 }} />
331
+ <span style={{ fontSize: 13.5, color: T.inkSoft, minWidth: 0 }}>New here? Take the 30-second tour.</span>
332
+ <button data-hot onClick={start} style={{ border: 'none', background: T.orange, color: T.white, cursor: 'pointer', borderRadius: 99, padding: '7px 14px', fontSize: 13, fontWeight: 600, fontFamily: T.body, flexShrink: 0 }}>\u25B6 Play</button>
333
+ <button data-hot onClick={dismiss} aria-label="Dismiss" style={{ border: 'none', background: 'none', color: T.muted, cursor: 'pointer', fontSize: 16, lineHeight: 1, padding: '0 6px', flexShrink: 0 }}>\xD7</button>
334
+ </motion.div>
335
+ )}
336
+ </AnimatePresence>
337
+ );
338
+ }
339
+
340
+ function TourCaption({ tour, stop }: { tour: number | null; stop: () => void }) {
341
+ return (
342
+ <AnimatePresence>
343
+ {tour !== null && tour < TOUR.length && (
344
+ <motion.div key={tour} initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 24 }} transition={T.spring}
345
+ style={{ position: 'fixed', left: 0, right: 0, marginInline: 'auto', bottom: 'clamp(26px,4vw,44px)', zIndex: 500, width: 'min(640px, calc(100vw - 32px))' }}>
346
+ <div style={{ background: T.navy, borderRadius: 14, padding: '16px 18px', boxShadow: '0 30px 60px -30px rgba(0,0,0,0.6)' }}>
347
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
348
+ <span style={{ fontFamily: T.mono, fontSize: 10, letterSpacing: '0.16em', color: T.orangeSoft, flexShrink: 0 }}>TOUR {tour + 1}/{TOUR.length}</span>
349
+ <span style={{ fontFamily: T.body, fontSize: 14.5, color: '#EEF1F6', lineHeight: 1.45 }}>{TOUR[tour]!.caption}</span>
350
+ <button data-hot onClick={stop} style={{ marginLeft: 'auto', flexShrink: 0, border: 'none', background: 'rgba(255,255,255,0.1)', color: '#C9D2E2', cursor: 'pointer', borderRadius: 8, padding: '5px 10px', fontSize: 12, fontFamily: T.body }}>End</button>
351
+ </div>
352
+ <div style={{ marginTop: 12, height: 3, background: 'rgba(255,255,255,0.12)', borderRadius: 99, overflow: 'hidden' }}>
353
+ <motion.div key={'p' + tour} initial={{ width: '0%' }} animate={{ width: '100%' }} transition={{ duration: TOUR[tour]!.ms / 1000, ease: 'linear' }} style={{ height: '100%', background: T.orange }} />
354
+ </div>
355
+ </div>
356
+ </motion.div>
357
+ )}
358
+ </AnimatePresence>
359
+ );
360
+ }
361
+
362
+ export default function App() {
363
+ const pulse = usePulse();
364
+ return (
365
+ <BrowserRouter>
366
+ <Shell pulse={pulse} />
367
+ </BrowserRouter>
368
+ );
369
+ }
370
+ `
371
+ },
372
+ {
373
+ "path": "src/components/Chrome.tsx",
374
+ "content": "import React, { useEffect, useRef } from 'react';\nimport { T } from '../theme';\n\n/** Fixed cinematic frame: letterbox bars, sprocket rails, projector wash, grain, reticle cursor. */\nexport function Chrome() {\n return (\n <>\n <div aria-hidden style={bar('top')} />\n <div aria-hidden style={bar('bottom')} />\n <div aria-hidden style={projector} />\n <Grain />\n <Cursor />\n </>\n );\n}\n\nconst bar = (side: 'top' | 'bottom'): React.CSSProperties => ({\n position: 'fixed', left: 0, right: 0, [side]: 0, height: 'clamp(14px,2.4vw,26px)',\n background: T.ink, zIndex: 400, pointerEvents: 'none',\n});\n\nconst projector: React.CSSProperties = {\n position: 'fixed', inset: 0, zIndex: 0, pointerEvents: 'none',\n background: 'radial-gradient(120% 60% at 50% -10%, rgba(212,98,43,0.10), transparent 60%)',\n};\n\n// Static, resolution-independent film grain via an SVG fractalNoise filter.\n// Renders crisp at any DPR (no chunky retina pixels) and reads as organic film\n// stock rather than salt-and-pepper canvas noise.\nconst GRAIN_SVG = \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23g)'/%3E%3C/svg%3E\";\nfunction Grain() {\n return <div aria-hidden style={{ position: 'fixed', inset: 0, zIndex: 300, pointerEvents: 'none', opacity: 0.28, mixBlendMode: 'multiply', backgroundImage: `url(\"${GRAIN_SVG}\")`, backgroundSize: '220px 220px' }} />;\n}\n\nfunction Cursor() {\n const ref = useRef<HTMLDivElement>(null);\n useEffect(() => {\n if (matchMedia('(pointer: coarse)').matches) return;\n const el = ref.current!; el.style.display = 'block';\n document.documentElement.style.cursor = 'none';\n let mx = 0, my = 0, cx = 0, cy = 0, raf = 0;\n const move = (e: MouseEvent) => { mx = e.clientX; my = e.clientY; };\n addEventListener('mousemove', move);\n const tick = () => { raf = requestAnimationFrame(tick); cx += (mx - cx) * 0.2; cy += (my - cy) * 0.2; el.style.transform = `translate(${cx}px,${cy}px)`; };\n tick();\n const over = (e: MouseEvent) => { el.dataset.hot = (e.target as Element)?.closest?.('a,button,[data-hot]') ? '1' : ''; };\n addEventListener('mouseover', over);\n return () => { cancelAnimationFrame(raf); removeEventListener('mousemove', move); removeEventListener('mouseover', over); document.documentElement.style.cursor = ''; };\n }, []);\n return (\n <div ref={ref} aria-hidden style={{ display: 'none', position: 'fixed', top: 0, left: 0, zIndex: 9999, pointerEvents: 'none', mixBlendMode: 'difference' }}>\n <div style={{ position: 'absolute', width: 22, height: 22, margin: '-11px 0 0 -11px' }}>\n <span style={{ position: 'absolute', top: '50%', left: 0, width: '100%', height: 1, background: '#fff', transform: 'translateY(-50%)' }} />\n <span style={{ position: 'absolute', left: '50%', top: 0, height: '100%', width: 1, background: '#fff', transform: 'translateX(-50%)' }} />\n </div>\n </div>\n );\n}\n"
375
+ },
376
+ {
377
+ "path": "src/components/CodeSeam.tsx",
378
+ "content": "import React from 'react';\nimport { motion } from 'framer-motion';\nimport { T } from '../theme';\n\ninterface Seam { tag: string; powers: string; caption: string; code: string; }\n\nconst SEAMS: Seam[] = [\n {\n tag: '@theatrical/events',\n powers: 'the live event stream',\n caption: 'Poll \u2192 diff \u2192 emit. Five lines turn a request-response API into a heartbeat.',\n code: `import { BookingWatcher } from '@theatrical/events';\n\nconst watcher = new BookingWatcher({\n fetch: (signal) => client.orders.list({}, signal),\n});\n\nwatcher.on('booking.confirmed', ({ order }) => {\n stream.push(order); // the pulse reaches the UI\n});\n\nwatcher.start();`,\n },\n {\n tag: '@theatrical/sdk',\n powers: 'the live board',\n caption: 'Typed, autocompleted, zero credentials in mock mode \u2014 real NZ cinema fixtures.',\n code: `import { TheatricalClient } from '@theatrical/sdk';\n\nconst client = TheatricalClient.createMock();\n\nconst { data: films } = await client.films.nowShowing();\nconst sessions = await client.sessions.list({\n siteId: 'roxy-wellington',\n});`,\n },\n {\n tag: '@theatrical/react',\n powers: 'step inside',\n caption: 'ARIA seat selection, wheelchair + companion markers, themeable, cinema-aware.',\n code: `import { SeatMap } from '@theatrical/react';\n\n<SeatMap\n rows={rows}\n selectedSeatIds={selected}\n onSeatSelect={toggle}\n maxSelectable={8}\n/>;`,\n },\n {\n tag: 'the slope',\n powers: 'mission control',\n caption: 'The interesting fact is never the count. It is the rate of change.',\n code: `// the photograph says: 88 seats sold\n// the pulse says:\nconst eta = (session.capacity - session.sold) / session.velocity;\n// \u2192 \"Poor Things 7:15 \u2014 sells out in ~6 minutes\"`,\n },\n];\n\nexport function CodeSeam() {\n return (\n <div style={{ maxWidth: 1100, margin: '0 auto', padding: 'clamp(28px,5vh,52px) clamp(20px,5vw,40px) 96px' }}>\n <header style={{ marginBottom: 40 }}>\n <span style={{ fontFamily: T.mono, fontSize: 12, letterSpacing: '0.24em', textTransform: 'uppercase', color: T.orange }}>Developer view</span>\n <h1 style={{ fontFamily: T.display, fontSize: 'clamp(2rem,5vw,3.4rem)', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, color: T.ink, margin: '12px 0 0' }}>\n The whole thing is <span style={{ color: T.orange }}>a few dozen lines.</span>\n </h1>\n <p style={{ color: T.muted, marginTop: 12, fontSize: 16, maxWidth: '58ch', lineHeight: 1.5 }}>\n Everything you just watched \u2014 the breathing board, the seat maps, the slopes \u2014 sits on top of Theatrical. Here is the wiring.\n </p>\n </header>\n\n <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%,440px), 1fr))', gap: 24 }}>\n {SEAMS.map((s, i) => (\n <motion.div key={s.tag} initial={{ opacity: 0, y: 18 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: i * 0.06, ...T.spring }}\n style={{ border: `1px solid ${T.border}`, borderRadius: 16, overflow: 'hidden', background: T.surfaceRaised }}>\n <div style={{ padding: '16px 20px 12px' }}>\n <code style={{ fontFamily: T.mono, fontSize: 13, color: T.orange }}>{s.tag}</code>\n <span style={{ color: T.muted, fontSize: 13 }}> &nbsp;powers {s.powers}</span>\n <p style={{ color: T.inkSoft, fontSize: 13.5, lineHeight: 1.5, marginTop: 6 }}>{s.caption}</p>\n </div>\n <Code code={s.code} />\n </motion.div>\n ))}\n </div>\n\n <p style={{ marginTop: 36, textAlign: 'center', color: T.muted, fontSize: 15 }}>\n <code style={{ fontFamily: T.mono, color: T.inkSoft }}>npx @theatrical/cli init</code> &nbsp;\xB7&nbsp; build the future of cinema this afternoon.\n </p>\n </div>\n );\n}\n\n/** Restrained syntax tone: package names cinnabar, comments dimmed, the rest soft. */\nfunction Code({ code }: { code: string }) {\n return (\n <pre style={{ margin: 0, padding: '18px 20px', background: T.navyDeep, overflowX: 'auto', fontFamily: T.mono, fontSize: 12.5, lineHeight: 1.7, color: '#C6D0E2' }}>\n {code.split('\\n').map((line, i) => {\n const ci = line.indexOf('//');\n const codePart = ci >= 0 ? line.slice(0, ci) : line;\n const comment = ci >= 0 ? line.slice(ci) : '';\n const segs = codePart.split(/(@theatrical\\/[a-z]+|'[^']*')/g);\n return (\n <div key={i}>\n {segs.map((seg, j) =>\n seg.startsWith('@theatrical/') ? <span key={j} style={{ color: T.orangeSoft }}>{seg}</span>\n : seg.startsWith(\"'\") ? <span key={j} style={{ color: '#9DD3A8' }}>{seg}</span>\n : <span key={j}>{seg}</span>,\n )}\n {comment && <span style={{ color: '#6E7E9C', fontStyle: 'italic' }}>{comment}</span>}\n {line === '' ? '\u200B' : ''}\n </div>\n );\n })}\n </pre>\n );\n}\n"
379
+ },
380
+ {
381
+ "path": "src/components/MissionControl.tsx",
382
+ "content": "import React, { useRef } from 'react';\nimport { motion, AnimatePresence } from 'framer-motion';\nimport { T } from '../theme';\nimport { FILMS, SCREENS, type LiveSession, type PulseState } from '../lib/cinema';\nimport { useIsMobile } from '../lib/responsive';\n\nconst film = (id: string) => FILMS.find((f) => f.id === id)!;\nconst fmtTime = (iso: string) => new Date(iso).toLocaleTimeString('en-NZ', { hour: 'numeric', minute: '2-digit', hour12: true });\n\nexport function MissionControl({ pulse }: { pulse: PulseState }) {\n const isMobile = useIsMobile();\n const occupancy = Math.round((pulse.totalSold / pulse.capacity) * 100);\n\n // sellout projections \u2014 the slope, not the number\n const now = Date.now();\n const projections = pulse.sessions\n .filter((s) => !s.soldOut && s.velocity > 0 && Date.parse(s.startTime) > now - 30 * 60000)\n .map((s) => ({ s, eta: (s.capacity - s.sold) / s.velocity })) // seconds\n .filter((p) => p.eta < 60 * 60) // within the hour\n .sort((a, b) => a.eta - b.eta)\n .slice(0, 4);\n\n // occupancy per screen\n const byScreen = Object.values(SCREENS).map((screen) => {\n const ss = pulse.sessions.filter((s) => s.screenId === screen.id);\n const sold = ss.reduce((a, s) => a + Math.floor(s.sold), 0);\n const cap = ss.reduce((a, s) => a + s.capacity, 0) || 1;\n return { screen, pct: Math.round((sold / cap) * 100) };\n });\n\n return (\n <div style={{ maxWidth: 1180, margin: '0 auto', padding: 'clamp(28px,5vh,52px) clamp(20px,5vw,40px) 96px' }}>\n <Head title=\"Mission control\" accent=\"The operator never looks.\" sub=\"The systems that need to know are already informed \u2014 the platform's pulse, surfaced. Slope, not snapshot.\" />\n\n <div style={{ display: 'flex', gap: 'clamp(20px,4vw,56px)', flexWrap: 'wrap', margin: '8px 0 36px', paddingBottom: 28, borderBottom: `1px solid ${T.border}` }}>\n <Vital label=\"Admissions tonight\" value={pulse.totalSold} />\n <Vital label=\"Box office\" value={Math.round(pulse.revenue)} prefix=\"$\" />\n <Vital label=\"House occupancy\" value={occupancy} suffix=\"%\" />\n <Vital label=\"Live sessions\" value={pulse.sessions.filter((s) => !s.soldOut).length} />\n <RevenueSpark value={pulse.revenue} />\n </div>\n\n <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0,1fr) clamp(280px,30%,360px)', gap: 'clamp(24px,4vw,48px)', alignItems: 'start' }}>\n {/* On mobile the live stream is the headline \u2014 surface it first. */}\n {isMobile && <Stream events={pulse.events} isMobile />}\n <div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>\n {/* projections */}\n <Panel label=\"Trajectory \u2014 projected sell-outs\">\n {projections.length === 0 && <Empty>No sessions trending to sell out right now.</Empty>}\n {projections.map(({ s, eta }) => <Projection key={s.id} s={s} eta={eta} />)}\n </Panel>\n {/* occupancy by screen */}\n <Panel label=\"House occupancy by screen\">\n {byScreen.map(({ screen, pct }) => (\n <div key={screen.id} style={{ display: 'grid', gridTemplateColumns: isMobile ? '92px 1fr 40px' : '160px 1fr 48px', gap: 14, alignItems: 'center', padding: '8px 0' }}>\n <span style={{ fontSize: 13, color: T.inkSoft, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{screen.name} <span style={{ color: T.muted, fontSize: 11 }}>\xB7 {screen.format}</span></span>\n <div style={{ height: 8, background: T.border, borderRadius: 99, overflow: 'hidden' }}>\n <motion.div animate={{ width: `${pct}%` }} transition={{ duration: 0.6, ease: T.easeOut }} style={{ height: '100%', borderRadius: 99, background: pct > 85 ? T.red : `linear-gradient(90deg,${T.navy},${T.orange})` }} />\n </div>\n <span style={{ fontFamily: T.mono, fontSize: 13, color: T.ink, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{pct}%</span>\n </div>\n ))}\n </Panel>\n {/* concessions slope */}\n <Panel label=\"Concessions \u2014 depletion forecast\">\n <Concession label=\"Popcorn (large)\" pct={62} note=\"runs dry ~21:10 \xB7 mid-intermission\" warn isMobile={isMobile} />\n <Concession label=\"Choc-tops\" pct={84} note=\"comfortable through tonight\" isMobile={isMobile} />\n <Concession label=\"Craft cider\" pct={41} note=\"reorder before the 20:00 rush\" warn isMobile={isMobile} />\n </Panel>\n </div>\n {!isMobile && <Stream events={pulse.events} />}\n </div>\n </div>\n );\n}\n\nfunction Projection({ s, eta }: { s: LiveSession; eta: number }) {\n const f = film(s.filmId);\n const mins = Math.max(1, Math.round(eta / 60));\n const urgent = mins <= 8;\n return (\n <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '12px 0', borderTop: `1px solid ${T.border}` }}>\n <div>\n <div style={{ fontFamily: T.display, fontSize: 16, fontWeight: 600, color: T.ink }}>{f.title} <span style={{ fontFamily: T.mono, fontSize: 12, color: T.muted, fontWeight: 400 }}>\xB7 {fmtTime(s.startTime)}</span></div>\n <div style={{ fontSize: 12.5, color: T.muted, marginTop: 2 }}>{s.capacity - Math.floor(s.sold)} seats left \xB7 {SCREENS[s.screenId]!.name}</div>\n </div>\n <div style={{ textAlign: 'right' }}>\n <div style={{ fontFamily: T.display, fontSize: 20, fontWeight: 700, color: urgent ? T.red : T.orange, letterSpacing: '-0.02em' }}>~{mins}m</div>\n <div style={{ fontFamily: T.mono, fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: T.muted }}>to sell out</div>\n </div>\n </div>\n );\n}\n\nfunction Concession({ label, pct, note, warn, isMobile }: { label: string; pct: number; note: string; warn?: boolean; isMobile?: boolean }) {\n return (\n <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '92px 1fr' : '130px 1fr', gap: 14, alignItems: 'center', padding: '9px 0' }}>\n <span style={{ fontSize: 13, color: T.inkSoft }}>{label}</span>\n <div>\n <div style={{ height: 6, background: T.border, borderRadius: 99, overflow: 'hidden' }}>\n <div style={{ width: `${pct}%`, height: '100%', borderRadius: 99, background: warn ? T.orange : T.green }} />\n </div>\n <div style={{ fontSize: 11.5, color: warn ? T.orange : T.muted, marginTop: 4 }}>{note}</div>\n </div>\n </div>\n );\n}\n\nfunction RevenueSpark({ value }: { value: number }) {\n const hist = useRef<number[]>([]);\n if (hist.current[hist.current.length - 1] !== value) hist.current = [...hist.current, value].slice(-40);\n const pts = hist.current;\n const min = Math.min(...pts, value), max = Math.max(...pts, value) || 1;\n const d = pts.map((v, i) => `${(i / Math.max(1, pts.length - 1)) * 120},${28 - ((v - min) / (max - min || 1)) * 26}`).join(' ');\n return (\n <div>\n <svg width={120} height={30} style={{ display: 'block' }}>\n <polyline points={d} fill=\"none\" stroke={T.orange} strokeWidth={1.6} strokeLinejoin=\"round\" />\n </svg>\n <div style={{ fontFamily: T.mono, fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase', color: T.muted, marginTop: 4 }}>Box office \xB7 live</div>\n </div>\n );\n}\n\nfunction Head({ title, accent, sub }: { title: string; accent: string; sub: string }) {\n return (\n <header style={{ marginBottom: 8 }}>\n <span style={{ fontFamily: T.mono, fontSize: 12, letterSpacing: '0.24em', textTransform: 'uppercase', color: T.orange }}>Operator view</span>\n <h1 style={{ fontFamily: T.display, fontSize: 'clamp(2rem,5vw,3.4rem)', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, color: T.ink, margin: '12px 0 0' }}>\n {title}. <span style={{ color: T.muted }}>{accent}</span>\n </h1>\n <p style={{ color: T.muted, marginTop: 12, fontSize: 16, maxWidth: '56ch', lineHeight: 1.5 }}>{sub}</p>\n </header>\n );\n}\n\nfunction Vital({ label, value, prefix = '', suffix = '' }: { label: string; value: number; prefix?: string; suffix?: string }) {\n return (\n <div>\n <div style={{ fontFamily: T.display, fontSize: 'clamp(1.6rem,3vw,2.4rem)', fontWeight: 700, color: T.ink, letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums' }}>{prefix}{value.toLocaleString('en-NZ')}{suffix}</div>\n <div style={{ fontFamily: T.mono, fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase', color: T.muted, marginTop: 4 }}>{label}</div>\n </div>\n );\n}\n\nfunction Panel({ label, children }: { label: string; children: React.ReactNode }) {\n return (\n <section style={{ border: `1px solid ${T.border}`, borderRadius: 14, background: T.surfaceRaised, padding: '18px 22px' }}>\n <div style={{ fontFamily: T.mono, fontSize: 11, letterSpacing: '0.16em', textTransform: 'uppercase', color: T.muted, marginBottom: 8 }}>{label}</div>\n {children}\n </section>\n );\n}\n\nfunction Empty({ children }: { children: React.ReactNode }) {\n return <div style={{ color: T.muted, fontSize: 13, padding: '8px 0' }}>{children}</div>;\n}\n\nconst EV: Record<string, { dot: string; label: (e: any) => string }> = {\n 'booking.confirmed': { dot: '#52B788', label: (e) => `${e.seats} seat${e.seats > 1 ? 's' : ''} \xB7 ${e.filmTitle} ${e.time}` },\n 'session.soldout': { dot: '#C4391D', label: (e) => `SOLD OUT \xB7 ${e.filmTitle} ${e.time}` },\n 'fnb.ordered': { dot: '#C9922A', label: (e) => e.item },\n 'loyalty.tier': { dot: '#3E5C8A', label: (e) => `${e.member} \u2192 ${e.tier}` },\n};\n\nfunction Stream({ events, isMobile }: { events: PulseState['events']; isMobile?: boolean }) {\n return (\n <aside style={{ position: isMobile ? 'static' : 'sticky', top: 96, border: `1px solid ${T.border}`, borderRadius: 14, background: T.navy, overflow: 'hidden' }}>\n <div style={{ padding: '14px 18px', borderBottom: '1px solid rgba(255,255,255,0.10)', display: 'flex', alignItems: 'center', gap: 8 }}>\n <motion.span animate={{ opacity: [1, 0.3, 1] }} transition={{ duration: 1.4, repeat: Infinity }} style={{ width: 7, height: 7, borderRadius: '50%', background: T.green }} />\n <span style={{ fontFamily: T.mono, fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase', color: '#C9D2E2' }}>Event stream</span>\n <span style={{ marginLeft: 'auto', fontFamily: T.mono, fontSize: 10, color: '#7E8CA6' }}>@theatrical/events</span>\n </div>\n <div style={{ padding: '8px 8px 14px', maxHeight: isMobile ? 360 : 560, overflow: 'hidden' }}>\n <AnimatePresence initial={false}>\n {events.slice(0, isMobile ? 8 : 15).map((e: any) => (\n <motion.div key={e.id ?? e.at + e.kind} layout initial={{ opacity: 0, x: 16, height: 0 }} animate={{ opacity: 1, x: 0, height: 'auto' }} exit={{ opacity: 0 }} transition={T.spring}\n style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px' }}>\n <span style={{ width: 7, height: 7, borderRadius: '50%', background: EV[e.kind].dot, flexShrink: 0 }} />\n <span style={{ fontFamily: T.mono, fontSize: 10.5, color: '#7E8CA6', flexShrink: 0 }}>{e.kind}</span>\n <span style={{ fontFamily: T.body, fontSize: 12.5, color: '#E8ECF3', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{EV[e.kind].label(e)}</span>\n </motion.div>\n ))}\n </AnimatePresence>\n </div>\n </aside>\n );\n}\n"
383
+ },
384
+ {
385
+ "path": "src/components/Poster.tsx",
386
+ "content": "import React from 'react';\nimport type { Film } from '../lib/cinema';\n\n/* Poster text scales to the poster's OWN width (container queries), so a long\n title like \"The Mandalorian & Grogu\" fits a 120px ticket stub and a full\n key-art poster alike \u2014 never clipped. Tagline/genres only show once the\n poster is wide enough to carry them. Injected once. */\nconst POSTER_CSS = `\n.thp{container-type:inline-size;}\n.thp-meta{position:absolute;left:clamp(10px,5cqw,18px);right:clamp(10px,5cqw,18px);bottom:clamp(12px,5cqw,18px);}\n.thp-title{font-family:'Space Grotesk',sans-serif;font-weight:700;line-height:1.02;letter-spacing:-0.02em;color:#F0EDE6;text-shadow:0 2px 18px rgba(0,0,0,0.45);overflow-wrap:break-word;font-size:clamp(13px,10.5cqw,30px);}\n.thp-tag{display:none;font-family:'JetBrains Mono',monospace;font-size:9.5px;letter-spacing:0.16em;text-transform:uppercase;color:rgba(255,255,255,0.8);margin-bottom:7px;text-shadow:0 1px 8px rgba(0,0,0,0.7);}\n.thp-genre{display:none;margin-top:9px;font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:0.14em;color:rgba(255,255,255,0.6);}\n@container (min-width:190px){.thp-tag{display:block;}.thp-genre{display:block;}}\n`;\nif (typeof document !== 'undefined' && !document.getElementById('thp-style')) {\n const s = document.createElement('style'); s.id = 'thp-style'; s.textContent = POSTER_CSS; document.head.appendChild(s);\n}\n\n/**\n * Bespoke film-poster artwork. No external images \u2014 every poster is composed\n * from the film's own palette + a motif, in the Roxy house style (think\n * minimalist A24/Criterion key art). A missing photo can never break it, and\n * the lineup reads as one designed season rather than scraped thumbnails.\n */\nexport function Poster({ film, height = 300, bare = false }: { film: Film; height?: number; bare?: boolean }) {\n const compact = height < 130;\n const ground = `linear-gradient(155deg, ${dark(film.accent, 0.32)} 0%, ${film.accent} 56%, ${dark(film.accent, 0.58)} 100%)`;\n\n return (\n <div className=\"thp\" style={{ height, position: 'relative', overflow: 'hidden', background: ground }}>\n {motif(film.motif, film.accent2, compact)}\n {/* film-grain / vignette */}\n <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(130% 100% at 50% -10%, rgba(255,255,255,0.10), transparent 45%), radial-gradient(120% 120% at 50% 120%, rgba(0,0,0,0.55), transparent 55%)' }} />\n <div style={{ position: 'absolute', inset: 0, opacity: 0.10, mixBlendMode: 'overlay', backgroundImage: 'url(\"data:image/svg+xml;utf8,<svg xmlns=\\'http://www.w3.org/2000/svg\\' width=\\'80\\' height=\\'80\\'><filter id=\\'n\\'><feTurbulence type=\\'fractalNoise\\' baseFrequency=\\'0.9\\' numOctaves=\\'2\\'/></filter><rect width=\\'100%25\\' height=\\'100%25\\' filter=\\'url(%23n)\\'/></svg>\")' }} />\n\n {!compact && !bare && (\n <>\n <div style={{ position: 'absolute', top: 13, left: 14, display: 'flex', gap: 6, alignItems: 'center' }}>\n <span style={badge()}>{film.classification}</span>\n <span style={{ fontFamily: \"'JetBrains Mono', monospace\", fontSize: 10, letterSpacing: '0.18em', color: 'rgba(255,255,255,0.72)' }}>{film.year}</span>\n </div>\n <div className=\"thp-meta\">\n <div className=\"thp-tag\">{film.tagline}</div>\n <div className=\"thp-title\">{film.title}</div>\n <div className=\"thp-genre\">{film.genres.join(' \xB7 ').toUpperCase()}</div>\n </div>\n </>\n )}\n </div>\n );\n}\n\n/** Each motif is an abstract, poster-scale composition keyed off the film. */\nfunction motif(kind: number, hl: string, compact: boolean) {\n const k = ((kind % 4) + 4) % 4;\n if (k === 0) {\n // ORB \u2014 a sun/planet/spotlight rising from the upper field.\n return (\n <>\n <div style={{ position: 'absolute', top: compact ? '-30%' : '-22%', left: '50%', transform: 'translateX(-50%)', width: '128%', aspectRatio: '1', borderRadius: '50%', background: `radial-gradient(circle at 50% 50%, ${light(hl, 0.2)} 0%, ${hl} 34%, transparent 62%)`, opacity: 0.92 }} />\n <div style={{ position: 'absolute', top: compact ? '-10%' : '2%', left: '50%', transform: 'translateX(-50%)', width: '70%', aspectRatio: '1', borderRadius: '50%', border: `1px solid ${light(hl, 0.35)}`, opacity: 0.35 }} />\n </>\n );\n }\n if (k === 1) {\n // BEAMS \u2014 vertical projector light streaking down.\n return (\n <>\n <div style={{ position: 'absolute', inset: 0, background: `repeating-linear-gradient(99deg, transparent 0 14px, ${hl}14 14px 15px)`, opacity: 0.5 }} />\n <div style={{ position: 'absolute', top: '-12%', left: '58%', width: '34%', height: '124%', transform: 'rotate(8deg)', background: `linear-gradient(180deg, ${light(hl, 0.3)} 0%, transparent 78%)`, opacity: 0.55, filter: 'blur(2px)' }} />\n <div style={{ position: 'absolute', top: '-12%', left: '26%', width: '4%', height: '124%', transform: 'rotate(8deg)', background: light(hl, 0.4), opacity: 0.7 }} />\n </>\n );\n }\n if (k === 2) {\n // ARC \u2014 concentric horizons swelling from below.\n return (\n <>\n {[0, 1, 2].map((i) => (\n <div key={i} style={{ position: 'absolute', bottom: `${-58 + i * 16}%`, left: '50%', transform: 'translateX(-50%)', width: `${150 - i * 26}%`, aspectRatio: '1', borderRadius: '50%', border: `1.5px solid ${hl}`, opacity: 0.16 + i * 0.16 }} />\n ))}\n <div style={{ position: 'absolute', bottom: '-46%', left: '50%', transform: 'translateX(-50%)', width: '70%', aspectRatio: '1', borderRadius: '50%', background: `radial-gradient(circle, ${hl} 0%, transparent 64%)`, opacity: 0.5 }} />\n </>\n );\n }\n // RIFT \u2014 a diagonal seam splitting the field.\n return (\n <>\n <div style={{ position: 'absolute', inset: '-20%', background: `linear-gradient(118deg, transparent 0 47%, ${light(hl, 0.35)} 49.4% 50.6%, transparent 53%)`, opacity: 0.85 }} />\n <div style={{ position: 'absolute', inset: 0, background: `linear-gradient(118deg, ${dark(hl, 0.2)} 0 49%, transparent 49% 100%)`, opacity: 0.45 }} />\n <div style={{ position: 'absolute', top: compact ? '8%' : '14%', right: '14%', width: '30%', aspectRatio: '1', borderRadius: '50%', background: `radial-gradient(circle, ${light(hl, 0.2)} 0%, transparent 65%)`, opacity: 0.6 }} />\n </>\n );\n}\n\nconst badge = (): React.CSSProperties => ({\n fontFamily: \"'JetBrains Mono', monospace\", fontSize: 10, fontWeight: 600, letterSpacing: '0.06em',\n color: '#11151F', background: 'rgba(240,237,230,0.94)', padding: '2px 7px', borderRadius: 4,\n});\n\n// ---- tiny colour helpers (hex \u2192 mixed rgb) ----\nfunction chan(h: string) { const x = h.replace('#', ''); return [0, 2, 4].map((i) => parseInt(x.slice(i, i + 2), 16)); }\nfunction mix(h: string, t: number[], p: number) { const a = chan(h); const m = a.map((v, i) => Math.round(v + (t[i]! - v) * p)); return `rgb(${m[0]},${m[1]},${m[2]})`; }\nfunction dark(h: string, p: number) { return mix(h, [0, 0, 0], p); }\nfunction light(h: string, p: number) { return mix(h, [255, 255, 255], p); }\n"
387
+ },
388
+ {
389
+ "path": "src/components/Timeboard.tsx",
390
+ "content": "import React from 'react';\nimport { motion } from 'framer-motion';\nimport { T } from '../theme';\nimport { FILMS, SCREENS, type LiveSession, type PulseState } from '../lib/cinema';\nimport { Poster } from './Poster';\nimport { useIsMobile } from '../lib/responsive';\n\nconst film = (id: string) => FILMS.find((f) => f.id === id)!;\nconst fmtTime = (iso: string) => new Date(iso).toLocaleTimeString('en-NZ', { hour: 'numeric', minute: '2-digit', hour12: true });\n\nfunction status(s: LiveSession): { label: string; color: string } {\n if (s.soldOut) return { label: 'SOLD OUT', color: T.red };\n const left = s.capacity - Math.floor(s.sold);\n if (left <= 10) return { label: `${left} left`, color: T.orange };\n if (s.sold / s.capacity > 0.7) return { label: 'Selling fast', color: T.gold };\n return { label: 'Good seats', color: T.green };\n}\n\nexport function Timeboard({ pulse, onOpen }: { pulse: PulseState; onOpen: (sessionId: string) => void }) {\n const sessions = [...pulse.sessions].sort((a, b) => Date.parse(a.startTime) - Date.parse(b.startTime));\n return (\n <div style={{ maxWidth: 900, margin: '0 auto', padding: 'clamp(28px,5vh,52px) clamp(20px,5vw,40px) 96px' }}>\n <header style={{ marginBottom: 32 }}>\n <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontFamily: T.mono, fontSize: 12, letterSpacing: '0.24em', textTransform: 'uppercase', color: T.orange }}>\n <motion.span animate={{ opacity: [1, 0.25, 1], scale: [1, 0.8, 1] }} transition={{ duration: 1.6, repeat: Infinity }} style={{ width: 8, height: 8, borderRadius: '50%', background: T.orange }} />\n Now showing \xB7 tonight\n </span>\n <h1 style={{ fontFamily: T.display, fontSize: 'clamp(2.2rem,5.5vw,3.8rem)', fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1, color: T.ink, margin: '14px 0 0' }}>\n Tonight at the <span style={{ color: T.orange }}>Roxy</span>\n </h1>\n <p style={{ color: T.muted, marginTop: 12, fontSize: 17, maxWidth: '44ch', lineHeight: 1.5 }}>\n Seats are filling in real time. Watch the bars move \u2014 then grab yours before they&rsquo;re gone.\n </p>\n </header>\n\n <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>\n {sessions.map((s) => <SessionRow key={s.id} s={s} onOpen={onOpen} />)}\n </div>\n </div>\n );\n}\n\nfunction SessionRow({ s, onOpen }: { s: LiveSession; onOpen: (id: string) => void }) {\n const isMobile = useIsMobile();\n const f = film(s.filmId);\n const screen = SCREENS[s.screenId]!;\n const st = status(s);\n const pct = Math.min(100, (s.sold / s.capacity) * 100);\n\n const bar = (\n <div style={{ height: 6, background: T.border, borderRadius: 99, overflow: 'hidden' }}>\n <motion.div animate={{ width: `${pct}%` }} transition={{ duration: 0.6, ease: T.easeOut }}\n style={{ height: '100%', borderRadius: 99, background: s.soldOut ? T.red : `linear-gradient(90deg, ${T.orange}, ${T.gold})` }} />\n </div>\n );\n\n if (isMobile) {\n return (\n <motion.button\n data-hot\n onClick={() => !s.soldOut && onOpen(s.id)}\n whileTap={s.soldOut ? {} : { scale: 0.99 }}\n transition={T.spring}\n style={{\n display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'stretch', textAlign: 'left',\n width: '100%', padding: 14, border: `1px solid ${T.border}`, borderRadius: 14,\n background: s.soldOut ? T.surface : T.surfaceRaised, cursor: s.soldOut ? 'default' : 'pointer',\n opacity: s.soldOut ? 0.62 : 1,\n }}\n >\n <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>\n <div style={{ width: 44, height: 62, borderRadius: 6, overflow: 'hidden', border: `1px solid ${T.border}`, flexShrink: 0 }}>\n <Poster film={f} height={62} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ fontFamily: T.display, fontSize: 16, fontWeight: 600, color: T.ink, letterSpacing: '-0.01em', lineHeight: 1.2 }}>{f.title}</div>\n <div style={{ marginTop: 3, fontFamily: T.mono, fontSize: 10.5, color: T.muted, letterSpacing: '0.06em' }}>{screen.name.toUpperCase()} \xB7 {screen.format.toUpperCase()}</div>\n </div>\n <div style={{ textAlign: 'right', flexShrink: 0 }}>\n <div style={{ fontFamily: T.mono, fontSize: 16, fontWeight: 500, color: T.ink, letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums' }}>{fmtTime(s.startTime)}</div>\n <div style={{ marginTop: 3, fontFamily: T.mono, fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase', color: st.color }}>{st.label}</div>\n </div>\n </div>\n {bar}\n <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>\n <span style={{ fontFamily: T.mono, fontSize: 10.5, color: T.muted, fontVariantNumeric: 'tabular-nums' }}>{Math.floor(s.sold)} / {s.capacity} seats</span>\n <span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 10 }}>\n <span style={{ fontFamily: T.body, fontSize: 13, color: T.muted }}>from ${s.priceFrom.toFixed(2)}</span>\n {!s.soldOut && <span style={{ fontSize: 13, color: T.orange, fontWeight: 600 }}>Book \u2192</span>}\n </span>\n </div>\n </motion.button>\n );\n }\n\n return (\n <motion.button\n data-hot\n onClick={() => !s.soldOut && onOpen(s.id)}\n whileHover={s.soldOut ? {} : { y: -2 }}\n transition={T.spring}\n style={{\n display: 'grid', gridTemplateColumns: '52px 78px 1fr auto', gap: 18, alignItems: 'center',\n textAlign: 'left', width: '100%', padding: '14px 18px 14px 14px', border: `1px solid ${T.border}`,\n borderRadius: 14, background: s.soldOut ? T.surface : T.surfaceRaised, cursor: s.soldOut ? 'default' : 'pointer',\n opacity: s.soldOut ? 0.62 : 1,\n }}\n >\n <div style={{ width: 52, height: 74, borderRadius: 7, overflow: 'hidden', border: `1px solid ${T.border}` }}>\n <Poster film={f} height={74} />\n </div>\n <div style={{ fontFamily: T.mono, fontSize: 19, fontWeight: 500, color: T.ink, letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums' }}>\n {fmtTime(s.startTime)}\n </div>\n <div style={{ minWidth: 0 }}>\n <div style={{ display: 'flex', alignItems: 'baseline', gap: 9, flexWrap: 'wrap' }}>\n <span style={{ fontFamily: T.display, fontSize: 18, fontWeight: 600, color: T.ink, letterSpacing: '-0.01em' }}>{f.title}</span>\n <span style={{ fontFamily: T.mono, fontSize: 10.5, color: T.muted, letterSpacing: '0.07em' }}>{screen.name.toUpperCase()} \xB7 {screen.format.toUpperCase()}</span>\n </div>\n <div style={{ marginTop: 9, height: 6, background: T.border, borderRadius: 99, overflow: 'hidden' }}>\n <motion.div animate={{ width: `${pct}%` }} transition={{ duration: 0.6, ease: T.easeOut }}\n style={{ height: '100%', borderRadius: 99, background: s.soldOut ? T.red : `linear-gradient(90deg, ${T.orange}, ${T.gold})` }} />\n </div>\n <div style={{ marginTop: 6, fontFamily: T.mono, fontSize: 10.5, color: T.muted, fontVariantNumeric: 'tabular-nums' }}>{Math.floor(s.sold)} / {s.capacity} seats</div>\n </div>\n <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6, minWidth: 88 }}>\n <span style={{ fontFamily: T.mono, fontSize: 10.5, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase', color: st.color }}>{st.label}</span>\n <span style={{ fontFamily: T.body, fontSize: 13, color: T.muted }}>from ${s.priceFrom.toFixed(2)}</span>\n {!s.soldOut && <span style={{ fontSize: 13, color: T.orange, fontWeight: 600 }}>Book \u2192</span>}\n </div>\n </motion.button>\n );\n}\n"
391
+ },
392
+ {
393
+ "path": "src/lib/cinema.ts",
394
+ "content": "import { useEffect, useRef, useState } from 'react';\nimport { BookingWatcher, SessionWatcher } from '@theatrical/events';\n\n/**\n * The living cinema.\n *\n * A request-response API hands you a photograph. This engine mutates real state\n * over time, then the REAL @theatrical/events watchers (BookingWatcher,\n * SessionWatcher) poll \u2192 diff \u2192 emit a typed event stream. The UI listens to the\n * pulse, not the photo. \"From snapshot to pulse,\" wired to the published package.\n */\n\nexport interface Film {\n id: string; title: string; year: number; runtime: number; classification: string;\n genres: string[]; synopsis: string; tagline: string; accent: string; accent2: string; motif: number;\n}\nexport interface ScreenSpec {\n id: string; name: string; format: 'Standard' | 'IMAX' | 'Gold Class' | 'Boutique';\n layout: number[]; aisleAfter?: number[];\n}\nexport interface LiveSession {\n id: string; filmId: string; screenId: string; startTime: string;\n priceFrom: number; capacity: number; sold: number; velocity: number; soldOut: boolean;\n}\ninterface LiveOrder { id: string; status: 'pending' | 'confirmed'; filmTitle: string; time: string; seats: number; }\n\nexport type PulseEvent =\n | { kind: 'booking.confirmed'; id: string; filmTitle: string; time: string; seats: number; at: number }\n | { kind: 'session.soldout'; id: string; filmTitle: string; time: string; at: number }\n | { kind: 'fnb.ordered'; id: string; item: string; at: number }\n | { kind: 'loyalty.tier'; id: string; member: string; tier: string; at: number };\n\n// \u2500\u2500\u2500 Curated programme \u2014 Roxy Cinema, Wellington. Of-the-moment, varied. \u2500\u2500\u2500\nexport const FILMS: Film[] = [\n { id: 'mando-grogu', title: 'The Mandalorian & Grogu', year: 2026, runtime: 124, classification: 'PG', genres: ['Sci-Fi', 'Adventure'], tagline: 'The galaxy\u2019s unlikeliest duo ride again.', synopsis: 'A lone bounty hunter and the galaxy\u2019s most wanted foundling take on a mission that could reshape the New Republic.', accent: '#2E6B4F', accent2: '#C9922A', motif: 0 },\n { id: 'disclosure-day', title: 'Disclosure Day', year: 2026, runtime: 129, classification: 'M', genres: ['Sci-Fi', 'Thriller'], tagline: 'The day they finally told us the truth.', synopsis: 'When every government on Earth releases its files at the same hour, a junior archivist realises the disclosure is itself the cover story.', accent: '#2E5E9E', accent2: '#9DD3E8', motif: 0 },\n { id: 'the-backrooms', title: 'The Backrooms', year: 2026, runtime: 101, classification: 'R16', genres: ['Horror', 'Thriller'], tagline: 'There is no exit. Only deeper.', synopsis: 'A teenager who films abandoned buildings clips through the floor of reality into an endless, humming maze of yellow rooms.', accent: '#7E7A2E', accent2: '#1E1C10', motif: 3 },\n { id: 'tuner', title: 'Tuner', year: 2026, runtime: 118, classification: 'M', genres: ['Thriller', 'Crime'], tagline: 'Every safe has a frequency.', synopsis: 'A concert piano tuner with perfect pitch is pulled into a crew that cracks vaults by ear \u2014 until one job hits a note that won\u2019t resolve.', accent: '#A66A2E', accent2: '#1A1208', motif: 1 },\n { id: 'obsession', title: 'Obsession', year: 2026, runtime: 112, classification: 'R16', genres: ['Drama', 'Thriller'], tagline: 'How far would you follow a feeling?', synopsis: 'A grief counsellor becomes convinced a stranger on her morning train is living the life her late sister was meant to have.', accent: '#9E2E3A', accent2: '#2A1015', motif: 3 },\n { id: 'sheep-detectives', title: 'The Sheep Detectives', year: 2026, runtime: 96, classification: 'G', genres: ['Animation', 'Comedy'], tagline: 'Two ewes. One mystery. No leads.', synopsis: 'When the prize ram vanishes the night before the county fair, two unlikely sheep turn gumshoe across the high-country farms of Otago.', accent: '#4F8A6B', accent2: '#F0E7C8', motif: 2 },\n { id: 'sentimental-value', title: 'Sentimental Value', year: 2026, runtime: 133, classification: 'M', genres: ['Drama'], tagline: 'A house remembers everyone who left.', synopsis: 'Two estranged sisters return to their childhood home when their filmmaker father offers one of them the lead in his comeback feature.', accent: '#8A6E3A', accent2: '#2A2014', motif: 2 },\n { id: 'after-the-hunt', title: 'After the Hunt', year: 2026, runtime: 138, classification: 'R16', genres: ['Thriller', 'Drama'], tagline: 'The truth has a cost. So does silence.', synopsis: 'A university professor is caught between a star student and a longtime colleague when a private accusation threatens to surface a secret of her own.', accent: '#3E4A6E', accent2: '#11151F', motif: 1 },\n];\n\nexport const SCREENS: Record<string, ScreenSpec> = {\n 'screen-1': { id: 'screen-1', name: 'Screen 1', format: 'Standard', layout: [12, 14, 14, 16, 16, 16, 14, 12], aisleAfter: [5, 10] },\n 'screen-2': { id: 'screen-2', name: 'Screen 2', format: 'IMAX', layout: [18, 20, 22, 22, 24, 24, 22, 20, 18], aisleAfter: [6, 16] },\n 'roxy-lounge': { id: 'roxy-lounge', name: 'The Roxy Lounge', format: 'Gold Class', layout: [4, 4, 6, 6, 6], aisleAfter: [2] },\n 'kiosk': { id: 'kiosk', name: 'Boutique 3', format: 'Boutique', layout: [6, 8, 8, 10, 8], aisleAfter: [4] },\n};\nconst SCREEN_IDS = Object.keys(SCREENS);\nconst seatsIn = (s: ScreenSpec) => s.layout.reduce((a, b) => a + b, 0);\n\nfunction buildProgramme(now: number): LiveSession[] {\n const sessions: LiveSession[] = [];\n const base = new Date(now); base.setHours(11, 0, 0, 0);\n const slots = [0, 2, 3.5, 5, 6.5, 8, 9.25]; // hours from 11am\n let i = 0;\n for (const h of slots) {\n const perSlot = h >= 5 ? 2 : 1; // busier in the evening\n for (let k = 0; k < perSlot; k++) {\n const film = FILMS[i % FILMS.length]!; // cycle through the whole catalogue \u2192 minimal repeats\n const screen = SCREENS[SCREEN_IDS[(i + k) % SCREEN_IDS.length]!]!;\n const start = new Date(base.getTime() + h * 3600_000);\n const cap = seatsIn(screen);\n const eveningHeat = h >= 6 ? 1.7 : 1;\n const premiumHeat = screen.format === 'Gold Class' || screen.format === 'IMAX' ? 1.4 : 1;\n const startSold = Math.floor(cap * (0.2 + ((i * 17) % 45) / 100));\n sessions.push({\n id: `s-${film.id}-${i}`, filmId: film.id, screenId: screen.id, startTime: start.toISOString(),\n priceFrom: screen.format === 'Gold Class' ? 45 : screen.format === 'IMAX' ? 28 : 18.5,\n capacity: cap, sold: Math.min(startSold, cap),\n velocity: 0.045 * eveningHeat * premiumHeat * (0.6 + ((i * 13) % 50) / 50), soldOut: false,\n });\n i++;\n }\n }\n return sessions;\n}\n\nconst FNB = ['Large popcorn', 'Roxy choc-top', 'Craft cider', 'Negroni', 'Kombucha', 'Salted caramel gelato'];\nconst NAMES = ['Hemi W.', 'Aroha T.', 'Sione F.', 'Mei L.', 'Jack R.', 'Priya N.', 'Tama K.', 'Eve S.'];\nconst TIERS = ['Silver', 'Gold', 'Platinum'];\nexport const filmOf = (id: string) => FILMS.find((f) => f.id === id)!;\nconst timeOf = (iso: string) => new Date(iso).toLocaleTimeString('en-NZ', { hour: 'numeric', minute: '2-digit', hour12: true });\n\nexport class LiveCinema {\n sessions: LiveSession[];\n private orders: LiveOrder[] = [];\n private seed = 7;\n private oid = 0;\n constructor(now: number) { this.sessions = buildProgramme(now); }\n private rnd() { this.seed = (this.seed * 1103515245 + 12345) & 0x7fffffff; return this.seed / 0x7fffffff; }\n\n /** Advance the world `dt` seconds; mutate seats + maintain the order book. */\n tick(dt: number, now: number) {\n // confirm last tick's pending orders (so the watcher sees a status transition)\n for (const o of this.orders) if (o.status === 'pending') o.status = 'confirmed';\n for (const s of this.sessions) {\n if (s.soldOut) continue;\n const minsToStart = (Date.parse(s.startTime) - now) / 60000;\n const window = minsToStart > -30 && minsToStart < 240 ? 1 : 0.15;\n const add = s.velocity * window * (0.5 + this.rnd()) * dt;\n const before = Math.floor(s.sold);\n s.sold = Math.min(s.capacity, s.sold + add);\n if (s.sold >= s.capacity - 0.5) { s.sold = s.capacity; s.soldOut = true; }\n const delta = Math.floor(s.sold) - before;\n if (delta > 0) this.orders.push({ id: `o${this.oid++}`, status: 'pending', filmTitle: filmOf(s.filmId).title, time: timeOf(s.startTime), seats: delta });\n }\n if (this.orders.length > 80) this.orders = this.orders.slice(-80);\n }\n // fresh snapshots each poll \u2192 the JSON diff can see status / isSoldOut transitions\n getOrders() { return this.orders.map((o) => ({ ...o })); }\n getSessions() { return this.sessions.map((s) => ({ id: s.id, isSoldOut: s.soldOut, filmTitle: filmOf(s.filmId).title, time: timeOf(s.startTime) })); }\n}\n\nexport interface PulseState {\n sessions: LiveSession[]; events: PulseEvent[];\n totalSold: number; capacity: number; revenue: number;\n}\n\nexport function usePulse(): PulseState {\n const [, force] = useState(0);\n const ref = useRef<LiveCinema>();\n const eventsRef = useRef<PulseEvent[]>([]);\n if (!ref.current) ref.current = new LiveCinema(Date.now());\n\n if (!eventsRef.current.length) {\n const c = ref.current; const now = Date.now();\n eventsRef.current = c.sessions.slice(0, 6).map((s, i) => {\n const f = filmOf(s.filmId);\n return { kind: 'booking.confirmed', id: 'seed' + i, filmTitle: f.title, time: timeOf(s.startTime), seats: 1 + (i % 3), at: now - i * 3200 } as PulseEvent;\n });\n }\n\n useEffect(() => {\n const cinema = ref.current!;\n const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n const push = (e: PulseEvent) => { eventsRef.current = [e, ...eventsRef.current].slice(0, 30); };\n\n // REAL @theatrical/events watchers \u2014 poll \u2192 diff \u2192 emit.\n const interval = reduce ? 1600 : 750;\n const booking = new BookingWatcher({ fetch: async () => cinema.getOrders() as never, intervalMs: interval });\n const session = new SessionWatcher({ fetch: async () => cinema.getSessions() as never, intervalMs: interval });\n booking.on('booking.confirmed', (e: any) => push({ kind: 'booking.confirmed', id: e.order.id, filmTitle: e.order.filmTitle, time: e.order.time, seats: e.order.seats, at: Date.now() }));\n session.on('session.soldout', (e: any) => push({ kind: 'session.soldout', id: e.session.id, filmTitle: e.session.filmTitle, time: e.session.time, at: Date.now() }));\n booking.start(); session.start();\n\n const stepMs = reduce ? 1500 : 700;\n let last = performance.now(); let acc = 0; let raf = 0; let timer = 0; let amb = 0;\n function frame(t: number) {\n raf = requestAnimationFrame(frame);\n acc += t - last; last = t;\n if (acc >= stepMs) {\n cinema.tick(acc / 1000, Date.now()); acc = 0;\n if (++amb % 2 === 0) push(Math.random() > 0.5\n ? { kind: 'fnb.ordered', id: 'f' + Date.now(), item: FNB[Math.floor(Math.random() * FNB.length)]!, at: Date.now() }\n : { kind: 'loyalty.tier', id: 'l' + Date.now(), member: NAMES[Math.floor(Math.random() * NAMES.length)]!, tier: TIERS[Math.floor(Math.random() * TIERS.length)]!, at: Date.now() });\n force((n) => (n + 1) % 1_000_000);\n }\n }\n if (reduce) { timer = window.setInterval(() => { cinema.tick(2, Date.now()); force((n) => n + 1); }, stepMs); }\n else { raf = requestAnimationFrame(frame); }\n return () => { cancelAnimationFrame(raf); clearInterval(timer); booking.stop(); session.stop(); };\n }, []);\n\n const c = ref.current!;\n const totalSold = c.sessions.reduce((a, s) => a + Math.floor(s.sold), 0);\n const capacity = c.sessions.reduce((a, s) => a + s.capacity, 0);\n const revenue = c.sessions.reduce((a, s) => a + Math.floor(s.sold) * s.priceFrom, 0);\n return { sessions: c.sessions, events: eventsRef.current, totalSold, capacity, revenue };\n}\n"
395
+ },
396
+ {
397
+ "path": "src/lib/responsive.ts",
398
+ "content": "import { useEffect, useState } from 'react';\n\n/** Subscribe to a media query. SSR-safe; re-renders on match changes. */\nexport function useMediaQuery(query: string): boolean {\n const [matches, setMatches] = useState(() =>\n typeof window !== 'undefined' ? window.matchMedia(query).matches : false,\n );\n useEffect(() => {\n const mql = window.matchMedia(query);\n const on = () => setMatches(mql.matches);\n on();\n mql.addEventListener('change', on);\n return () => mql.removeEventListener('change', on);\n }, [query]);\n return matches;\n}\n\n/** Phone-sized viewports where the multi-column desktop layouts stop fitting. */\nexport const useIsMobile = () => useMediaQuery('(max-width: 760px)');\n"
399
+ },
400
+ {
401
+ "path": "src/main.tsx",
402
+ "content": "import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport App from './App';\n\ncreateRoot(document.getElementById('root')!).render(\n <React.StrictMode>\n <App />\n </React.StrictMode>\n);\n"
403
+ },
404
+ {
405
+ "path": "src/pages/Confirmation.tsx",
406
+ "content": `import React from 'react';
407
+ import { useLocation, useNavigate } from 'react-router-dom';
408
+ import { motion } from 'framer-motion';
409
+ import { T } from '../theme';
410
+ import { Poster } from '../components/Poster';
411
+ import { filmOf } from '../lib/cinema';
412
+ import { useIsMobile } from '../lib/responsive';
413
+
414
+ interface Booked {
415
+ filmId: string; filmTitle: string; screen: string; format: string; time: string; seats: string[]; price: number;
416
+ }
417
+
418
+ export function ConfirmationPage() {
419
+ const navigate = useNavigate();
420
+ const isMobile = useIsMobile();
421
+ const state = (useLocation().state ?? null) as Booked | null;
422
+ if (!state) { navigate('/'); return null; }
423
+ const film = filmOf(state.filmId);
424
+
425
+ const total = state.seats.length * state.price;
426
+ const when = new Date(state.time).toLocaleString('en-NZ', { weekday: 'long', day: 'numeric', month: 'long', hour: 'numeric', minute: '2-digit', hour12: true });
427
+
428
+ // The stub art is a bare, recoloured panel (no title) \u2014 the film's palette + motif,
429
+ // so the ticket reads as designed key art and never has to wrap a long title.
430
+ const artBand = isMobile ? 132 : 168;
431
+
432
+ return (
433
+ <div style={{ maxWidth: 640, margin: '0 auto', padding: '56px clamp(20px,5vw,40px) 96px', textAlign: 'center' }}>
434
+ <motion.p initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}
435
+ style={{ fontFamily: T.mono, fontSize: 12, letterSpacing: '0.24em', textTransform: 'uppercase', color: T.orange }}>
436
+ Booking confirmed
437
+ </motion.p>
438
+ <motion.h1 initial={{ opacity: 0, y: 14 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.18, ...T.spring }}
439
+ style={{ fontFamily: T.display, fontSize: 'clamp(2rem,5vw,3rem)', fontWeight: 700, color: T.ink, letterSpacing: '-0.03em', margin: '12px 0 32px' }}>
440
+ Enjoy the show.
441
+ </motion.h1>
442
+
443
+ {/* the ticket */}
444
+ <motion.div initial={{ opacity: 0, y: 28, rotateX: 14 }} animate={{ opacity: 1, y: 0, rotateX: 0 }} transition={{ delay: 0.26, ...T.spring }}
445
+ style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: 'stretch', textAlign: 'left', background: T.surfaceRaised, border: \`1px solid \${T.border}\`, borderRadius: 16, overflow: 'hidden', boxShadow: '0 40px 80px -50px rgba(27,45,79,0.5)' }}>
446
+ <div style={{ flexShrink: 0, height: artBand, width: isMobile ? 'auto' : 156 }}>
447
+ <Poster film={film} height={artBand} bare />
448
+ </div>
449
+ {/* perforation \u2014 vertical on desktop, horizontal on mobile */}
450
+ {isMobile
451
+ ? <div style={{ height: 2, background: \`repeating-linear-gradient(90deg, \${T.border} 0 7px, transparent 7px 14px)\` }} />
452
+ : <div style={{ width: 2, background: \`repeating-linear-gradient(180deg, \${T.border} 0 7px, transparent 7px 14px)\` }} />}
453
+ <div style={{ position: 'relative', padding: '22px 24px 30px', flex: 1, overflow: 'hidden' }}>
454
+ <div style={{ fontFamily: T.display, fontSize: 'clamp(18px,5vw,22px)', fontWeight: 700, color: T.ink, letterSpacing: '-0.02em', lineHeight: 1.1 }}>{state.filmTitle}</div>
455
+ <Row label="When" value={when} />
456
+ <Row label="Where" value={\`\${state.screen} \xB7 \${state.format}\`} />
457
+ <Row label="Seats" value={state.seats.join(', ')} />
458
+ <Row label="Total" value={\`$\${total.toFixed(2)} NZD\`} />
459
+ <div style={{ marginTop: 16, fontFamily: T.mono, fontSize: 10, letterSpacing: '0.3em', color: T.muted }}>
460
+ ROXY \xB7 WELLINGTON \xB7 {state.seats.length} ADMIT
461
+ </div>
462
+ <Stamp />
463
+ </div>
464
+ </motion.div>
465
+
466
+ <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.5 }} style={{ color: T.muted, margin: '28px 0 24px', fontSize: 14 }}>
467
+ Tickets sent to your phone. The auditorium opens 20 minutes before the show.
468
+ </motion.p>
469
+ <button data-hot onClick={() => navigate('/')}
470
+ style={{ background: T.ink, color: T.bg, border: 'none', borderRadius: 10, padding: '12px 26px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: T.body }}>
471
+ Back to the board
472
+ </button>
473
+ </div>
474
+ );
475
+ }
476
+
477
+ /** Washed-out rubber-stamp watermark \u2014 a final hand-finished flourish. */
478
+ function Stamp() {
479
+ return (
480
+ <div
481
+ aria-hidden
482
+ style={{
483
+ position: 'absolute', right: 14, bottom: 12, transform: 'rotate(-7deg)',
484
+ opacity: 0.16, pointerEvents: 'none', filter: 'blur(0.4px)',
485
+ color: T.navy, border: \`1.5px solid \${T.navy}\`, borderRadius: 7,
486
+ padding: '4px 9px 5px', textAlign: 'center',
487
+ }}
488
+ >
489
+ <div style={{ fontFamily: T.mono, fontSize: 6.5, letterSpacing: '0.2em', marginBottom: 1 }}>BROUGHT TO YOU BY</div>
490
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, fontFamily: T.display, fontWeight: 700, fontSize: 12, letterSpacing: '0.06em' }}>
491
+ <svg width="10" height="10" viewBox="0 0 32 32" style={{ display: 'block' }}>
492
+ <rect width="32" height="32" rx="6" fill={T.navy} />
493
+ <rect x="8" y="8" width="16" height="16" rx="3" fill={T.orange} />
494
+ <rect x="13" y="13" width="6" height="6" rx="1.5" fill={T.bg} />
495
+ </svg>
496
+ THEATRICAL
497
+ </div>
498
+ </div>
499
+ );
500
+ }
501
+
502
+ function Row({ label, value }: { label: string; value: string }) {
503
+ return (
504
+ <div style={{ display: 'flex', gap: 12, marginTop: 12 }}>
505
+ <span style={{ fontFamily: T.mono, fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: T.muted, width: 48, flexShrink: 0, paddingTop: 2 }}>{label}</span>
506
+ <span style={{ fontFamily: T.body, fontSize: 14, color: T.inkSoft }}>{value}</span>
507
+ </div>
508
+ );
509
+ }
510
+ `
511
+ },
512
+ {
513
+ "path": "src/pages/Seats.tsx",
514
+ "content": "import React, { useMemo, useState } from 'react';\nimport { useParams, useNavigate } from 'react-router-dom';\nimport { motion } from 'framer-motion';\nimport { T } from '../theme';\nimport { FILMS, SCREENS, type PulseState } from '../lib/cinema';\nimport { useIsMobile } from '../lib/responsive';\n\nconst ROWS = 'ABCDEFGHIJKLMN';\ntype SeatState = 'available' | 'taken' | 'premium' | 'wheelchair' | 'companion';\nconst COLOR: Record<SeatState | 'selected', string> = {\n available: T.navy, taken: T.border, premium: T.gold, wheelchair: '#2A6F97', companion: T.green, selected: T.orange,\n};\n\n/** stable hash \u2192 [0,1) so a seat's \"fill order\" is consistent across ticks */\nfunction h(str: string) { let x = 2166136261; for (let i = 0; i < str.length; i++) { x ^= str.charCodeAt(i); x = Math.imul(x, 16777619); } return ((x >>> 0) % 1000) / 1000; }\n\nexport function SeatsPage({ pulse }: { pulse: PulseState }) {\n const { sessionId } = useParams();\n const navigate = useNavigate();\n const isMobile = useIsMobile();\n const [selected, setSelected] = useState<Set<string>>(new Set());\n const session = pulse.sessions.find((s) => s.id === sessionId);\n const screen = session ? SCREENS[session.screenId] : undefined;\n\n // per-seat base state (premium band + accessibility), deterministic per screen\n const baseState = useMemo(() => {\n const map = new Map<string, SeatState>();\n if (!screen) return map;\n const midRow = Math.floor(screen.layout.length / 2);\n screen.layout.forEach((count, ri) => {\n const rowL = ROWS[ri];\n for (let c = 1; c <= count; c++) {\n const id = `${rowL}${c}`;\n let st: SeatState = 'available';\n // premium band \u2014 best seats, middle rows, centre block\n if ((screen.format === 'Gold Class') || (Math.abs(ri - midRow) <= 1 && c > count * 0.3 && c < count * 0.7)) st = 'premium';\n if (ri === 0 && (c === 1 || c === count)) st = 'wheelchair';\n if (ri === 0 && (c === 2 || c === count - 1)) st = 'companion';\n map.set(id, st);\n }\n });\n return map;\n }, [screen]);\n\n if (!session || !screen) { navigate('/'); return null; }\n const ses = session;\n const scr = screen;\n const f = FILMS.find((x) => x.id === ses.filmId)!;\n const soldRatio = ses.sold / ses.capacity;\n // Seat sizing: scale to fit the viewport width on mobile so the auditorium\n // never forces a horizontal scroll; keep the comfortable desktop sizes.\n const gap = isMobile ? 4 : 6;\n const aisleW = isMobile ? 10 : 14;\n let seatSize = scr.format === 'Gold Class' ? 30 : scr.layout.some((n) => n >= 22) ? 18 : 22;\n if (isMobile && typeof window !== 'undefined') {\n const maxCols = Math.max(...scr.layout);\n const aisles = scr.aisleAfter?.length ?? 0;\n const avail = window.innerWidth - 92; // page + card padding + row-label clearance\n const fit = Math.floor((avail - (maxCols - 1) * gap - aisles * aisleW) / maxCols);\n seatSize = Math.max(8, Math.min(seatSize, fit));\n }\n\n function toggle(id: string, taken: boolean) {\n if (taken) return;\n setSelected((p) => { const n = new Set(p); if (n.has(id)) n.delete(id); else if (n.size < 8) n.add(id); return n; });\n }\n\n function confirm() {\n if (!selected.size) return;\n navigate('/done', { state: { filmId: f.id, filmTitle: f.title, screen: scr.name, format: scr.format, time: ses.startTime, seats: [...selected], price: ses.priceFrom } });\n }\n\n return (\n <div style={{ maxWidth: 980, margin: '0 auto', padding: '40px clamp(20px,5vw,40px) 96px' }}>\n <button data-hot onClick={() => navigate('/')} style={{ color: T.orange, background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, marginBottom: 24 }}>\u2190 Back to the board</button>\n\n <div style={{ display: 'flex', gap: 24, alignItems: 'flex-end', marginBottom: 32, flexWrap: 'wrap' }}>\n <h1 style={{ fontFamily: T.display, fontSize: 'clamp(2rem,4.5vw,3rem)', fontWeight: 700, color: T.ink, letterSpacing: '-0.03em', lineHeight: 1 }}>{f.title}</h1>\n <p style={{ color: T.muted, fontFamily: T.mono, fontSize: 13 }}>\n {screen.name} \xB7 {screen.format} \xB7 {new Date(session.startTime).toLocaleString('en-NZ', { weekday: 'short', day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true })}\n </p>\n </div>\n\n <div style={{ border: `1px solid ${T.border}`, borderRadius: 18, background: T.surface, padding: 'clamp(20px,4vw,40px)', overflowX: 'auto' }}>\n {/* the screen */}\n <div style={{ position: 'relative', marginBottom: 34, textAlign: 'center' }}>\n <div style={{ height: 8, width: '62%', margin: '0 auto', borderRadius: '0 0 50% 50%', background: `linear-gradient(180deg, ${T.orange}, transparent)`, boxShadow: `0 0 40px ${T.orange}44` }} />\n <div style={{ fontFamily: T.mono, fontSize: 10, letterSpacing: '0.4em', color: T.muted, marginTop: 6 }}>SCREEN</div>\n </div>\n\n <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap }}>\n {screen.layout.map((count, ri) => {\n const rowL = ROWS[ri];\n return (\n <div key={rowL} style={{ display: 'flex', alignItems: 'center', gap, position: 'relative' }}>\n <span style={{ position: 'absolute', left: -22, fontFamily: T.mono, fontSize: 10, color: T.muted }}>{rowL}</span>\n {Array.from({ length: count }, (_, i) => {\n const c = i + 1;\n const id = `${rowL}${c}`;\n const base = baseState.get(id)!;\n const taken = base !== 'wheelchair' && base !== 'companion' && h(`${screen.id}-${id}`) < soldRatio;\n const isSel = selected.has(id);\n const state: SeatState | 'selected' = isSel ? 'selected' : taken ? 'taken' : base;\n const aisle = screen.aisleAfter?.includes(c) ? aisleW : 0;\n return (\n <React.Fragment key={id}>\n <motion.button\n data-hot\n onClick={() => toggle(id, taken)}\n whileHover={taken ? {} : { scale: 1.18 }}\n animate={{ scale: isSel ? 1.12 : 1 }}\n transition={T.spring}\n title={`${id}${base === 'premium' ? ' \xB7 Premium' : base === 'wheelchair' ? ' \xB7 Accessible' : ''}`}\n style={{\n width: seatSize, height: seatSize, borderRadius: screen.format === 'Gold Class' ? 8 : 5,\n border: 'none', padding: 0, cursor: taken ? 'not-allowed' : 'pointer',\n background: COLOR[state], boxShadow: isSel ? `0 0 0 2px ${T.bg}, 0 0 0 4px ${T.orange}` : 'none',\n opacity: taken ? 0.5 : 1,\n }}\n />\n {aisle > 0 && <span style={{ width: aisle }} />}\n </React.Fragment>\n );\n })}\n </div>\n );\n })}\n </div>\n\n <div style={{ marginTop: 28, textAlign: 'center', fontFamily: T.mono, fontSize: 12, color: T.muted }}>\n {selected.size} of 8 selected \xB7 {Math.floor(session.sold)}/{session.capacity} sold\n </div>\n <div style={{ marginTop: 16, display: 'flex', justifyContent: 'center', gap: 18, flexWrap: 'wrap', paddingTop: 16, borderTop: `1px solid ${T.border}` }}>\n {(['available', 'selected', 'taken', 'premium', 'wheelchair', 'companion'] as const).map((k) => (\n <span key={k} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: T.muted, textTransform: 'capitalize' }}>\n <span style={{ width: 12, height: 12, borderRadius: 3, background: COLOR[k] }} />{k}\n </span>\n ))}\n </div>\n </div>\n\n <div style={{\n marginTop: 28, display: 'flex', justifyContent: isMobile ? 'space-between' : 'flex-end', alignItems: 'center', gap: 18,\n ...(isMobile ? ({\n position: 'sticky', bottom: 'calc(clamp(14px,2.4vw,26px) + 12px)', zIndex: 60,\n background: 'rgba(240,237,230,0.94)', backdropFilter: 'blur(8px)',\n border: `1px solid ${T.border}`, borderRadius: 12, padding: '10px 14px',\n boxShadow: '0 18px 40px -26px rgba(27,45,79,0.5)',\n } as React.CSSProperties) : {}),\n }}>\n <span style={{ color: T.muted, fontSize: 14 }}>{selected.size} seat{selected.size !== 1 ? 's' : ''} \xB7 ${(selected.size * session.priceFrom).toFixed(2)}</span>\n <motion.button data-hot whileHover={{ scale: selected.size ? 1.03 : 1 }} whileTap={{ scale: 0.97 }} onClick={confirm} disabled={!selected.size}\n style={{ background: selected.size ? T.orange : T.border, color: selected.size ? T.white : T.muted, border: 'none', borderRadius: 10, padding: isMobile ? '12px 22px' : '13px 30px', fontSize: 15, fontWeight: 600, fontFamily: T.body, cursor: selected.size ? 'pointer' : 'not-allowed', flexShrink: 0 }}>\n Confirm seats \u2192\n </motion.button>\n </div>\n </div>\n );\n}\n"
515
+ },
516
+ {
517
+ "path": "src/theme.ts",
518
+ "content": `// designedbybruno \u2014 parchment warmth, cinnabar signature, navy structure, film grain.
519
+ export const T = {
520
+ bg: '#F0EDE6',
521
+ surface: '#F5F0E1',
522
+ surfaceRaised: '#FBF8F0',
523
+ ink: '#1A1A1A',
524
+ inkSoft: '#3A362F',
525
+ muted: '#8A8578',
526
+ border: '#D6D0C4',
527
+ navy: '#1B2D4F',
528
+ navyDeep: '#14213D',
529
+ orange: '#D4622B',
530
+ orangeSoft: '#E8844A',
531
+ gold: '#C9922A',
532
+ red: '#C4391D',
533
+ green: '#52B788',
534
+ cream: '#F5F0E1',
535
+ white: '#FAFAF7',
536
+ display: "'Space Grotesk', 'Arial Black', sans-serif",
537
+ body: "'Inter', system-ui, sans-serif",
538
+ mono: "'JetBrains Mono', 'SF Mono', monospace",
539
+ easeOut: [0.16, 1, 0.3, 1] as [number, number, number, number],
540
+ spring: { type: 'spring', stiffness: 380, damping: 30 } as const,
541
+ };
542
+ `
543
+ },
544
+ {
545
+ "path": "tsconfig.json",
546
+ "content": '{\n "compilerOptions": {\n "target": "ES2022",\n "useDefineForClassFields": true,\n "lib": ["ES2022", "DOM", "DOM.Iterable"],\n "module": "ESNext",\n "skipLibCheck": true,\n "moduleResolution": "bundler",\n "allowImportingTsExtensions": true,\n "resolveJsonModule": true,\n "isolatedModules": true,\n "noEmit": true,\n "jsx": "react-jsx",\n "strict": true,\n "noUnusedLocals": false,\n "noUnusedParameters": false\n },\n "include": ["src"]\n}\n'
547
+ },
548
+ {
549
+ "path": "vercel.json",
550
+ "content": '{\n "framework": "vite",\n "outputDirectory": "dist",\n "buildCommand": "npm run build",\n "installCommand": "npm install",\n "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]\n}\n'
551
+ },
552
+ {
553
+ "path": "vite.config.ts",
554
+ "content": "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n"
555
+ }
556
+ ];
557
+
558
+ // src/templates/index.ts
559
+ var VALID_TEMPLATES = [
560
+ "default",
561
+ "fullstack",
562
+ "worker",
563
+ "react-ticketing"
564
+ ];
565
+ function isValidTemplate(value) {
566
+ return VALID_TEMPLATES.includes(value);
567
+ }
568
+ function getTemplate(template, context) {
569
+ const builders = {
570
+ default: () => buildDefaultTemplate(context),
571
+ fullstack: () => buildFullstackTemplate(context),
572
+ worker: () => buildWorkerTemplate(context),
573
+ "react-ticketing": () => buildReactTicketingTemplate(context)
574
+ };
575
+ return builders[template]();
576
+ }
577
+ function buildDefaultTemplate(ctx) {
578
+ return {
579
+ name: "default",
580
+ description: "Minimal TypeScript project with Theatrical SDK",
581
+ files: [
582
+ packageJson(ctx),
583
+ tsConfig(),
584
+ envFile(ctx),
585
+ envExample(),
586
+ gitignore(),
587
+ entryPoint()
588
+ ]
589
+ };
590
+ }
591
+ function buildFullstackTemplate(ctx) {
592
+ const base = buildDefaultTemplate(ctx);
593
+ return {
594
+ name: "fullstack",
595
+ description: "Express API + React frontend with Theatrical SDK",
596
+ files: [...base.files, serverFile()]
597
+ };
598
+ }
599
+ function buildWorkerTemplate(ctx) {
600
+ const base = buildDefaultTemplate(ctx);
601
+ return {
602
+ name: "worker",
603
+ description: "Background worker for cinema event processing",
604
+ files: [...base.files, workerFile()]
605
+ };
606
+ }
607
+ function buildReactTicketingTemplate(ctx) {
608
+ return {
609
+ name: "react-ticketing",
610
+ description: "Living cinema booking demo \u2014 @theatrical/events watchers driving a React UI",
611
+ files: REACT_TICKETING_FILES.map((file) => ({
612
+ path: file.path,
613
+ content: file.content.replaceAll("{{PROJECT_NAME}}", ctx.projectName)
614
+ }))
615
+ };
616
+ }
617
+ function packageJson(ctx) {
618
+ return {
619
+ path: "package.json",
620
+ content: JSON.stringify(
621
+ {
622
+ name: ctx.projectName,
623
+ version: "0.1.0",
624
+ private: true,
625
+ type: "module",
626
+ scripts: {
627
+ build: "tsc",
628
+ dev: "tsx watch src/index.ts",
629
+ start: "node dist/index.js"
630
+ },
631
+ dependencies: {
632
+ "@theatrical/sdk": "^0.1.0"
633
+ },
634
+ devDependencies: {
635
+ "@theatrical/cli": "^0.1.0",
636
+ typescript: "^5.4.0",
637
+ tsx: "^4.0.0",
638
+ "@types/node": "^20.0.0"
639
+ }
640
+ },
641
+ null,
642
+ 2
643
+ ) + "\n"
644
+ };
645
+ }
646
+ function tsConfig() {
647
+ return {
648
+ path: "tsconfig.json",
649
+ content: JSON.stringify(
650
+ {
651
+ compilerOptions: {
652
+ target: "ES2022",
653
+ module: "ESNext",
654
+ moduleResolution: "bundler",
655
+ strict: true,
656
+ esModuleInterop: true,
657
+ outDir: "dist",
658
+ rootDir: "src",
659
+ declaration: true
660
+ },
661
+ include: ["src/**/*"]
662
+ },
663
+ null,
664
+ 2
665
+ ) + "\n"
666
+ };
667
+ }
668
+ function envFile(ctx) {
669
+ return {
670
+ path: ".env",
671
+ content: [
672
+ "# Theatrical SDK Configuration",
673
+ `THEATRICAL_API_KEY=${ctx.apiKey ?? "your-api-key-here"}`,
674
+ "# Base URL of your cinema platform API (optional \u2014 defaults to the sandbox environment)",
675
+ "# THEATRICAL_API_URL=https://your-platform-host.example.com",
676
+ ""
677
+ ].join("\n")
678
+ };
679
+ }
680
+ function envExample() {
681
+ return {
682
+ path: ".env.example",
683
+ content: [
684
+ "# Theatrical SDK Configuration",
685
+ "THEATRICAL_API_KEY=your-api-key-here",
686
+ "# Base URL of your cinema platform API (optional \u2014 defaults to the sandbox environment)",
687
+ "# THEATRICAL_API_URL=https://your-platform-host.example.com",
688
+ ""
689
+ ].join("\n")
690
+ };
691
+ }
692
+ function gitignore() {
693
+ return {
694
+ path: ".gitignore",
695
+ content: ["node_modules", "dist", ".env", "coverage", "*.log", ""].join("\n")
696
+ };
697
+ }
698
+ function entryPoint() {
699
+ return {
700
+ path: "src/index.ts",
701
+ content: [
702
+ "import { TheatricalClient } from '@theatrical/sdk';",
703
+ "",
704
+ "// Initialize the SDK client \u2014 use createMock() to run without credentials",
705
+ "const client = process.env.THEATRICAL_API_KEY",
706
+ " ? TheatricalClient.create({",
707
+ " apiKey: process.env.THEATRICAL_API_KEY,",
708
+ " baseUrl: process.env.THEATRICAL_API_URL,",
709
+ " })",
710
+ " : TheatricalClient.createMock();",
711
+ "",
712
+ "async function main() {",
713
+ " // List films currently showing",
714
+ " const films = await client.films.nowShowing();",
715
+ " console.log('Now Showing:', films.map((f) => f.title));",
716
+ "}",
717
+ "",
718
+ "main().catch(console.error);",
719
+ ""
720
+ ].join("\n")
721
+ };
722
+ }
723
+ function serverFile() {
724
+ return {
725
+ path: "src/server.ts",
726
+ content: [
727
+ "import express from 'express';",
728
+ "import { TheatricalClient } from '@theatrical/sdk';",
729
+ "",
730
+ "const app = express();",
731
+ "const port = process.env.PORT ?? 3000;",
732
+ "",
733
+ "const client = TheatricalClient.create({",
734
+ " apiKey: process.env.THEATRICAL_API_KEY ?? '',",
735
+ "});",
736
+ "",
737
+ "app.get('/api/films', async (_req, res) => {",
738
+ " const films = await client.films.nowShowing();",
739
+ " res.json(films);",
740
+ "});",
741
+ "",
742
+ "app.get('/api/sessions/:siteId', async (req, res) => {",
743
+ " const sessions = await client.sessions.list({",
744
+ " siteId: req.params.siteId,",
745
+ " });",
746
+ " res.json(sessions);",
747
+ "});",
748
+ "",
749
+ "app.listen(port, () => {",
750
+ " console.log(`Cinema API running on http://localhost:${port}`);",
751
+ "});",
752
+ ""
753
+ ].join("\n")
754
+ };
755
+ }
756
+ function workerFile() {
757
+ return {
758
+ path: "src/worker.ts",
759
+ content: [
760
+ "import { TheatricalClient } from '@theatrical/sdk';",
761
+ "",
762
+ "const client = TheatricalClient.create({",
763
+ " apiKey: process.env.THEATRICAL_API_KEY ?? '',",
764
+ "});",
765
+ "",
766
+ "/** Poll interval in milliseconds */",
767
+ "const POLL_INTERVAL = 30_000;",
768
+ "",
769
+ "async function processEvents() {",
770
+ " console.log('Cinema event worker starting...');",
771
+ "",
772
+ " setInterval(async () => {",
773
+ " try {",
774
+ " console.log(`[${new Date().toISOString()}] Polling for updates...`);",
775
+ " } catch (err) {",
776
+ " console.error('Poll error:', err);",
777
+ " }",
778
+ " }, POLL_INTERVAL);",
779
+ "}",
780
+ "",
781
+ "processEvents().catch(console.error);",
782
+ ""
783
+ ].join("\n")
784
+ };
785
+ }
786
+
787
+ // src/commands/init.ts
140
788
  function createInitCommand() {
141
789
  const cmd = new Command("init");
142
- cmd.argument("[project-name]", "Name of the project to create").description("Scaffold a new cinema platform project").option("-t, --template <template>", "Project template (default, fullstack, worker)", "default").option("--api-key <key>", "Vista API key to include in .env").option("--no-install", "Skip automatic dependency installation").option("-d, --directory <dir>", "Target directory (defaults to project name)").action(async (projectName, opts) => {
790
+ cmd.argument("[project-name]", "Name of the project to create").description("Scaffold a new cinema platform project").option("-t, --template <template>", `Project template (${VALID_TEMPLATES.join(", ")})`, "default").option("--api-key <key>", "API key to include in .env").option("--no-install", "Skip automatic dependency installation").option("-d, --directory <dir>", "Target directory (defaults to project name)").action(async (projectName, opts) => {
143
791
  const resolvedConfig = cmd.parent?.opts().resolvedConfig;
144
792
  const options = resolveInitOptions(projectName, opts, resolvedConfig);
145
793
  await executeInit(options);
@@ -158,8 +806,7 @@ function resolveInitOptions(projectName, flags, config) {
158
806
  };
159
807
  }
160
808
  function validateTemplate(template) {
161
- const valid = ["default", "fullstack", "worker"];
162
- if (valid.includes(template)) {
809
+ if (isValidTemplate(template)) {
163
810
  return template;
164
811
  }
165
812
  console.warn(error(`Unknown template "${template}". Using "default".`));
@@ -183,7 +830,10 @@ async function executeInit(options) {
183
830
  }
184
831
  fs2.mkdirSync(targetDir, { recursive: true });
185
832
  fs2.mkdirSync(path2.join(targetDir, "src"), { recursive: true });
186
- const template = getTemplate(options);
833
+ const template = getTemplate(options.template, {
834
+ projectName: options.projectName,
835
+ apiKey: options.apiKey
836
+ });
187
837
  let filesWritten = 0;
188
838
  for (const file of template.files) {
189
839
  const filePath = path2.join(targetDir, file.path);
@@ -204,204 +854,19 @@ async function executeInit(options) {
204
854
  console.log();
205
855
  console.log(highlight("Next steps:"));
206
856
  console.log(dim(` cd ${options.directory}`));
207
- if (options.apiKey) {
208
- console.log(dim(" # API key is configured in .env"));
857
+ if (options.template === "react-ticketing") {
858
+ console.log(dim(" npm install"));
859
+ console.log(dim(" npm run dev # self-contained \u2014 no API key required"));
209
860
  } else {
210
- console.log(dim(" # Add your Vista API key to .env"));
861
+ if (options.apiKey) {
862
+ console.log(dim(" # API key is configured in .env"));
863
+ } else {
864
+ console.log(dim(" # Add your platform API key to .env (or omit it to use mock mode)"));
865
+ }
866
+ console.log(dim(" npx theatrical inspect sessions list --site <your-site-id>"));
211
867
  }
212
- console.log(dim(" npx theatrical inspect sessions list --site <your-site-id>"));
213
868
  console.log();
214
869
  }
215
- function getTemplate(options) {
216
- const templates = {
217
- default: () => defaultTemplate(options),
218
- fullstack: () => fullstackTemplate(options),
219
- worker: () => workerTemplate(options)
220
- };
221
- return templates[options.template]();
222
- }
223
- function defaultTemplate(options) {
224
- return {
225
- name: "default",
226
- description: "Minimal TypeScript project with Theatrical SDK",
227
- files: [
228
- {
229
- path: "package.json",
230
- content: JSON.stringify(
231
- {
232
- name: options.projectName,
233
- version: "0.1.0",
234
- private: true,
235
- type: "module",
236
- scripts: {
237
- build: "tsc",
238
- dev: "tsx watch src/index.ts",
239
- start: "node dist/index.js"
240
- },
241
- dependencies: {
242
- "@theatrical/sdk": "^0.1.0"
243
- },
244
- devDependencies: {
245
- typescript: "^5.4.0",
246
- tsx: "^4.0.0",
247
- "@types/node": "^20.0.0"
248
- }
249
- },
250
- null,
251
- 2
252
- ) + "\n"
253
- },
254
- {
255
- path: "tsconfig.json",
256
- content: JSON.stringify(
257
- {
258
- compilerOptions: {
259
- target: "ES2022",
260
- module: "ESNext",
261
- moduleResolution: "bundler",
262
- strict: true,
263
- esModuleInterop: true,
264
- outDir: "dist",
265
- rootDir: "src",
266
- declaration: true
267
- },
268
- include: ["src/**/*"]
269
- },
270
- null,
271
- 2
272
- ) + "\n"
273
- },
274
- {
275
- path: ".env",
276
- content: [
277
- "# Theatrical SDK Configuration",
278
- `THEATRICAL_API_KEY=${options.apiKey ?? "your-api-key-here"}`,
279
- "THEATRICAL_API_URL=https://api.vista.co/ocapi/v1",
280
- ""
281
- ].join("\n")
282
- },
283
- {
284
- path: ".env.example",
285
- content: [
286
- "# Theatrical SDK Configuration",
287
- "THEATRICAL_API_KEY=your-api-key-here",
288
- "THEATRICAL_API_URL=https://api.vista.co/ocapi/v1",
289
- ""
290
- ].join("\n")
291
- },
292
- {
293
- path: ".gitignore",
294
- content: ["node_modules", "dist", ".env", "coverage", "*.log", ""].join("\n")
295
- },
296
- {
297
- path: "src/index.ts",
298
- content: [
299
- "import { TheatricalClient } from '@theatrical/sdk';",
300
- "",
301
- "// Initialize the SDK client",
302
- "const client = TheatricalClient.create({",
303
- " apiKey: process.env.THEATRICAL_API_KEY ?? '',",
304
- " baseUrl: process.env.THEATRICAL_API_URL ?? 'https://api.vista.co/ocapi/v1',",
305
- "});",
306
- "",
307
- "async function main() {",
308
- " // List films currently showing",
309
- " const films = await client.films.list({ nowShowing: true });",
310
- " console.log('Now Showing:', films);",
311
- "",
312
- " // Find nearby cinema sites",
313
- " // const sites = await client.sites.nearby(-41.2924, 174.7787, 10);",
314
- " // console.log('Nearby Sites:', sites);",
315
- "}",
316
- "",
317
- "main().catch(console.error);",
318
- ""
319
- ].join("\n")
320
- }
321
- ]
322
- };
323
- }
324
- function fullstackTemplate(options) {
325
- const base = defaultTemplate(options);
326
- return {
327
- name: "fullstack",
328
- description: "Express API + React frontend with Theatrical SDK",
329
- files: [
330
- ...base.files,
331
- {
332
- path: "src/server.ts",
333
- content: [
334
- "import express from 'express';",
335
- "import { TheatricalClient } from '@theatrical/sdk';",
336
- "",
337
- "const app = express();",
338
- "const port = process.env.PORT ?? 3000;",
339
- "",
340
- "const client = TheatricalClient.create({",
341
- " apiKey: process.env.THEATRICAL_API_KEY ?? '',",
342
- "});",
343
- "",
344
- "app.get('/api/films', async (_req, res) => {",
345
- " const films = await client.films.list({ nowShowing: true });",
346
- " res.json(films);",
347
- "});",
348
- "",
349
- "app.get('/api/sessions/:siteId', async (req, res) => {",
350
- " const sessions = await client.sessions.list({",
351
- " siteId: req.params.siteId,",
352
- " });",
353
- " res.json(sessions);",
354
- "});",
355
- "",
356
- "app.listen(port, () => {",
357
- " console.log(`\u{1F3AC} Cinema API running on http://localhost:${port}`);",
358
- "});",
359
- ""
360
- ].join("\n")
361
- }
362
- ]
363
- };
364
- }
365
- function workerTemplate(options) {
366
- const base = defaultTemplate(options);
367
- return {
368
- name: "worker",
369
- description: "Background worker for cinema event processing",
370
- files: [
371
- ...base.files,
372
- {
373
- path: "src/worker.ts",
374
- content: [
375
- "import { TheatricalClient } from '@theatrical/sdk';",
376
- "",
377
- "const client = TheatricalClient.create({",
378
- " apiKey: process.env.THEATRICAL_API_KEY ?? '',",
379
- "});",
380
- "",
381
- "/** Poll interval in milliseconds */",
382
- "const POLL_INTERVAL = 30_000;",
383
- "",
384
- "async function processEvents() {",
385
- " console.log('\u{1F3AC} Cinema event worker starting...');",
386
- "",
387
- " // Example: poll for session changes",
388
- " setInterval(async () => {",
389
- " try {",
390
- " // Check for updated sessions, availability changes, etc.",
391
- " console.log(`[${new Date().toISOString()}] Polling for updates...`);",
392
- " } catch (err) {",
393
- " console.error('Poll error:', err);",
394
- " }",
395
- " }, POLL_INTERVAL);",
396
- "}",
397
- "",
398
- "processEvents().catch(console.error);",
399
- ""
400
- ].join("\n")
401
- }
402
- ]
403
- };
404
- }
405
870
 
406
871
  // src/commands/codegen.ts
407
872
  import fs3 from "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theatrical/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Developer CLI for cinema platform APIs — scaffold, codegen, and inspect",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,7 +19,7 @@
19
19
  "inquirer": "^9.2.0",
20
20
  "cosmiconfig": "^9.0.0",
21
21
  "ora": "^8.0.0",
22
- "@theatrical/sdk": "^0.1.0"
22
+ "@theatrical/sdk": "^0.1.1"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^20.0.0",
@@ -56,6 +56,8 @@
56
56
  "test:watch": "vitest",
57
57
  "typecheck": "tsc --noEmit",
58
58
  "lint": "eslint src/",
59
- "clean": "rm -rf dist coverage"
59
+ "clean": "rm -rf dist coverage",
60
+ "sync-templates": "node scripts/sync-templates.mjs",
61
+ "prebuild": "npm run sync-templates"
60
62
  }
61
63
  }