diff --git a/README.md b/README.md index ae80904..05115ef 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,234 @@ -# Next +# Next — Real-Time Queue System -> A calm, persistent real-time queue for small service teams. +> A calm, persistent queue for small service teams, built as a production-ready portfolio project by Oniel Alejo Feliz. -Next uses a transactional Supabase PostgreSQL engine. Anonymous browser identities can create or join queues; a one-time queue capability grants staff membership; customer, staff, and public-display clients converge through filtered Realtime invalidations followed by authoritative revisioned snapshots. +[Live application](https://next-queue-omega.vercel.app) · [Try the demo](https://next-queue-omega.vercel.app/demo) · [Watch the 27-second demo](docs/assets/video/next-v1-demo.mp4) · [Read the v1.0 release notes](docs/releases/v1.0.0.md) -The production application is live at [next-queue-omega.vercel.app](https://next-queue-omega.vercel.app). It runs on Vercel Hobby with one Supabase Free project in `us-east-1`; no paid service, trial, analytics, storage add-on, or custom domain is part of the deployment. +![Next landing page](docs/assets/screenshots/landing-page.png) -## Product surfaces +## Product overview -- `/demo` — create a persistent queue and receive its staff code once -- `/q/[slug]` — customer check-in, number, position, and service state -- `/q/[slug]/staff` — capability claim and authorized queue commands -- `/q/[slug]/display` — public-safe current and upcoming numbers -- `/` and `/about` — product and project context +Next gives customers, staff, and a public display one synchronized answer to three questions: who is waiting, who is being served, and who is next. It replaces paper lists and shouted names with an intentionally small workflow that works without permanent accounts, contact details, tracking, or paid infrastructure. -## Identity and privacy +The v1.0 product includes: -Supabase anonymous sign-in creates a unique user UUID without email, phone, password, social identity, address, or demographics. That user uses PostgreSQL's `authenticated` role; it is different from the public publishable key and the unauthenticated `anon` database role. The session is cookie-backed through `@supabase/ssr` and normally survives refreshes and same-profile tabs. Clearing site data, using another device, or signing out loses the anonymous identity. +- anonymous queue creation with a one-time staff capability +- customer check-in, stable queue number, position, and turn status +- a staff board for call, complete, skip, pause, reopen, and close commands +- a distance-readable public display containing queue numbers only +- persistent PostgreSQL state and multi-client Realtime convergence +- responsive light/dark presentation, reduced motion, and accessible status feedback -Optional customer names live in `queue_entry_private`. Public Realtime tables and public snapshots contain number labels only. No tracking, analytics, advertising, or visitor profiling is installed. +## Live demo -## Local requirements +Open the [production demo](https://next-queue-omega.vercel.app/demo), create a synthetic queue, and save the one-time staff code privately. The creator is authorized immediately. Open customer, staff, and display routes in separate tabs or browser profiles to see changes converge without refresh. -- Node.js 22 -- npm -- Docker Desktop or another Docker-compatible runtime with at least 7 GB available +Use synthetic information only. An optional customer first name is visible only to authorized staff. Clearing browser data loses that anonymous identity, and a lost staff code cannot be recovered through the product. -```bash -npm install -npm run db:start -npm run db:reset +## Screenshots + +| Queue creation | Customer status | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| ![Create a persistent queue](docs/assets/screenshots/queue-creation.png) | ![Customer queue status](docs/assets/screenshots/customer-status.png) | + +| Staff board | Public display | +| ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| ![Staff queue board with a synthetic waiting list](docs/assets/screenshots/staff-board.png) | ![Public display showing the active number](docs/assets/screenshots/public-display.png) | + +| Mobile | Dark theme | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| ![Responsive customer view on mobile](docs/assets/screenshots/mobile-view.png) | ![Next landing page in dark theme](docs/assets/screenshots/dark-theme.png) | + +All portfolio media was captured from the final production build with a temporary synthetic queue. The queue, related rows, anonymous identities, and one-time capability were removed after capture. + +## Architecture + +Next.js serves the interface from Vercel. The browser uses only a Supabase project URL and publishable key; authorization and state transitions remain inside PostgreSQL. + +```mermaid +flowchart LR + subgraph Clients["Browser clients"] + Customer["Customer"] + Staff["Staff"] + Display["Public display"] + end + + Vercel["Vercel Hobby
Next.js 16"] + + subgraph Supabase["Supabase Free · us-east-1"] + Auth["Anonymous Auth"] + API["Data API + RPC"] + DB[("PostgreSQL
RLS + constraints")] + RT["Filtered Realtime
queues + queue_entries"] + end + + Vercel -->|"serves UI"| Clients + Clients -->|"public configuration"| Auth + Clients -->|"authenticated commands / snapshots"| API + API --> DB + DB -->|"committed public changes"| RT + RT -->|"invalidation"| Clients ``` -Copy `.env.example` to `.env.local`, then use the local API URL and **publishable** key reported by the CLI. Do not place a secret/service-role key in the browser environment. +The database has eight application tables. Public state lives in `queues` and `queue_entries`; private names, ownership, staff membership, access hashes, rate-limit attempts, idempotency receipts, and events remain outside the Realtime publication. See the [data model](docs/architecture/data-model.md) and [architecture decision record](docs/architecture/adr-002-supabase-realtime.md). + +## Data flow + +Customer, staff, and display clients subscribe independently. Realtime messages are treated as invalidations, not authoritative state: every signal produces a fresh revisioned snapshot. + +```mermaid +sequenceDiagram + participant Staff + participant RPC as PostgreSQL RPC + participant RT as Supabase Realtime + participant Customer + participant Display + + Staff->>RPC: call_next(request_id) + activate RPC + RPC->>RPC: authorize, lock, transition, increment revision + RPC-->>Staff: authoritative snapshot + deactivate RPC + RPC-->>RT: committed queues / queue_entries changes + par Customer refreshes + RT-->>Customer: filtered invalidation + Customer->>RPC: get_queue_snapshot(slug) + RPC-->>Customer: latest revision + and Staff confirms + RT-->>Staff: filtered invalidation + Staff->>RPC: get_queue_snapshot(slug) + RPC-->>Staff: latest revision + and Display refreshes + RT-->>Display: filtered invalidation + Display->>RPC: get_public_queue_snapshot(slug) + RPC-->>Display: display-safe latest revision + end +``` -```bash -npm run dev +## Database command flow + +Clients cannot write tables directly. Every mutation crosses an explicit security-definer RPC with a caller-generated request UUID. + +```mermaid +flowchart TD + A["Client intent + request UUID"] --> B["Require auth.uid()"] + B --> C{"Authorized for command?"} + C -->|"No"| X["Typed safe error"] + C -->|"Yes"| D{"Receipt already exists?"} + D -->|"Same actor + command"| R["Return current authoritative snapshot"] + D -->|"Mismatched reuse"| X + D -->|"New request"| E["Lock queue / entry rows"] + E --> F["Validate domain transition"] + F --> G["Apply one atomic state change"] + G --> H["Increment monotonic queue revision"] + H --> I["Insert command receipt + append-only event"] + I --> J["Commit and return fresh snapshot"] ``` -Open [http://localhost:3000/demo](http://localhost:3000/demo). Create a queue, save the one-time staff code privately, then open the customer, staff, and display routes in separate browser contexts. The deterministic `north-star-cafe` seed is public-state visual data only and intentionally has no recoverable staff code; automated tests create isolated queues and consume their one-time code. +## Security and privacy -The local stack is development-only, uses default local credentials, and must not be exposed to public traffic. +- Supabase anonymous Auth creates a browser-scoped UUID without email, phone, password, or social identity. +- The application runtime contains no service-role key, database password, or Supabase access token. +- All eight public-schema tables have RLS; direct writes are revoked from browser roles. +- Staff access is a queue-scoped capability. Only a `pgcrypto` hash is stored; the raw code is returned once and never enters a URL, event, log, seed, or Realtime payload. +- Optional names live in `queue_entry_private`. Public snapshots and displays expose number labels only. +- Failed staff claims are throttled per anonymous identity and queue. This is basic abuse resistance, not enterprise bot protection. +- No analytics, advertising, uploads, contact collection, or visitor profiling is enabled. -## Live demo +The full threat and authorization model is documented in [authorization and security](docs/security/authorization.md). -Open the [production demo](https://next-queue-omega.vercel.app/demo), create a queue, and save the staff code when it appears: the raw code is shown once and cannot be recovered. The creator is immediately authorized. To test synchronization, open the customer, staff, and display links in separate tabs or isolated browser profiles. A second staff profile must claim access with the code. Queue state persists remotely, while staff membership and customer ownership follow each profile's anonymous session. +## Concurrency and idempotency -Clearing site data, signing out, or changing browsers loses that anonymous identity. Losing both the creating staff session and the one-time code means staff access cannot be recovered through the product. Do not enter sensitive or regulated personal information; a display name is optional. +Commands execute in database transactions with queue/entry row locks. A partial unique index enforces at most one `SERVING` entry per queue, while unique `(queue_id, sequence)` and `(queue_id, number_label)` constraints prevent duplicate or reused numbers. -## Commands +Every intent receives a request UUID. Automatic retry reuses that UUID; the database records one command receipt, returns the existing result for a valid replay, and rejects mismatched reuse. Concurrent `call_next` requests therefore produce one winner and a typed conflict without violating the one-serving invariant. -```bash -npm run db:start -npm run db:stop -npm run db:status -npm run db:reset -npm run db:lint -npm run db:test -npm run db:types -npm run db:types:check -npm run test:integration -npm run test:realtime -npm run test:e2e -npm run test:production -npm run format -npm run format:check -npm run lint -npm run typecheck -npm run test -npm run build -npm audit -``` +## Realtime synchronization -`npm run test:production` is a read-only smoke check against the public URL. Set `PRODUCTION_BASE_URL` to test another deployment. `PRODUCTION_QUEUE_SLUG` optionally adds HTTP health checks for an existing synthetic queue; browser checks remain on public pages so the suite never creates an anonymous identity or mutates remote records. +Only display-safe `queues` and `queue_entries` rows are published. Each client subscribes with a queue-ID filter, debounces related transaction messages for 75 ms, fetches a full snapshot, and ignores stale revisions. A refresh already in flight queues at most one follow-up. -`db:reset` drops only the local database, replays every migration, and reapplies safe seed data. `db:types` regenerates `src/lib/supabase/database.types.ts`; do not edit that file manually. +Clients resynchronize after initial subscription, browser `online`, channel recovery, and a meaningful visibility return. The visible state returns to connected only after an authoritative refresh succeeds. Healthy clients do not poll. See the [Realtime protocol](docs/architecture/realtime-protocol.md). -## Command and authorization model +## Testing -Clients have no direct mutation grants. Explicit security-definer RPCs implement create, staff claim, join, call, complete, skip, pause, reopen, and close. Each validates `auth.uid()`, uses a fixed empty `search_path`, locks queue/entry rows, increments the queue revision once, records an idempotency receipt and append-only event, and returns a fresh snapshot. RLS separately limits table reads. +The release evidence contains 131 passing automated checks without double-counting the Realtime unit subset: -One partial unique index permits at most one `SERVING` entry per queue. `(queue_id, sequence)` and `(queue_id, number_label)` are unique, queue numbers are never reused, and position is calculated from ordered waiting rows. +| Layer | Passing checks | What it covers | +| ---------------------- | -------------: | ------------------------------------------------------------------------------------------------------- | +| Vitest unit/component | 34 | transitions, UI behavior, session handling, revision convergence; includes 9 focused Realtime tests | +| pgTAP database | 62 | RLS, grants, constraints, functions, privacy, publication, concurrency, idempotency | +| Supabase integration | 11 | persistent commands and authorization through the public client | +| Playwright application | 15 | end-to-end workflows and multi-client synchronization | +| Production smoke | 9 | public health, metadata, accessibility, console cleanliness, responsive overflow; remote-data read-only | -## Realtime and reconnection +CI runs formatting, lint, type generation/typecheck, unit tests, production build, dependency audit, a clean local Supabase migration replay, database lint, pgTAP, generated-type verification, integration tests, and browser tests. -Only `queues` and display-safe `queue_entries` are in `supabase_realtime`. Each surface subscribes with a queue-ID filter. A change is an invalidation signal: related messages are debounced for 75 ms, a snapshot RPC is fetched, stale revisions are ignored, and the new authoritative state replaces local state. The client resynchronizes after subscription, browser online, channel recovery, and a meaningful visibility return. Healthy connections do not poll. +## Accessibility -## Cost boundary +The interface uses semantic landmarks, one page heading, labeled forms, keyboard-reachable controls, visible focus, skip links, readable connection states, and status announcements. Queue state never relies on color alone. Automated Axe checks report zero serious or critical findings across public and application routes. -Production uses one Supabase Free project and one Vercel Hobby project only: no card, trial, compute upgrade, paid backup, PITR, log drain, paid analytics, paid storage, support plan, custom domain, or usage-based add-on. Free projects have finite database, bandwidth, build, function, and connection quotas and Supabase may pause an inactive project. No keep-alive is used to evade pausing. Re-check provider limits before relying on the service. +Manual production QA covered keyboard-only navigation, focus retention during Realtime updates, 200% zoom, 320–1440 px layouts, a 1920×1080 public display, dark theme, and reduced motion. Details are in the [production validation report](docs/releases/story-3-production-validation.md). -This portfolio deployment is provided as-is, without a commercial SLA. It has database-enforced authorization and basic access-attempt throttling, but not enterprise abuse protection, guaranteed recovery, or capacity for unbounded traffic. +## Deployment -## Production deployment +- **Application:** Vercel Hobby, production branch `main`, Node.js 22.x +- **Database/Auth/Realtime:** one Supabase Free project in `us-east-1` +- **Canonical URL:** [next-queue-omega.vercel.app](https://next-queue-omega.vercel.app) +- **Production browser variables:** `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` only +- **Migrations:** forward-only SQL in `supabase/migrations`, replayed from a clean database in CI -Vercel is connected to `XonkelX/next-queue`, uses `main` as the production branch, Node.js 22.x, and the standard Next.js build. Only these Production variables are configured: +No custom domain, paid analytics, paid storage, add-on, trial, payment method, or usage-based service is active. -```text -NEXT_PUBLIC_SUPABASE_URL -NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY +## Free-tier limitations + +This portfolio deployment has no commercial SLA. Supabase Free may pause an inactive project and both providers impose finite compute, database, bandwidth, build, function, and connection limits. The design is appropriate for small queues, not unbounded traffic or enterprise operations. + +Anonymous ownership is lost when browser data is cleared. A lost one-time staff code is unrecoverable without an already-authorized session. Automated anonymous-user retention, capability recovery/rotation, distributed rate limiting, monitoring, backups, and formal support are outside v1.0. + +## Local setup + +Requirements: Node.js 22, npm, and Docker Desktop (or a compatible Docker runtime with roughly 7 GB available). + +```bash +git clone https://github.com/XonkelX/next-queue.git +cd next-queue +npm install +npm run db:start +npm run db:reset +``` + +Copy `.env.example` to `.env.local`, then use the local API URL and **publishable** key shown by `npm run db:status`. Never put a service-role key in a browser variable. + +```bash +npm run dev ``` -Deployments are produced by pushing reviewed commits to `main`; a manual equivalent is `npx vercel deploy --prod`. The browser uses only public Supabase configuration—never a service-role key or database password. See the [Story 3 production validation](docs/releases/story-3-production-validation.md) for the release evidence and remaining risks. +Open [http://localhost:3000/demo](http://localhost:3000/demo). The deterministic `north-star-cafe` seed contains display-safe visual data and intentionally has no recoverable staff code. + +Useful validation commands: + +```bash +npm run format:check +npm run lint +npm run typecheck +npm test +npm run build +npm audit +``` + +Database and browser validation additionally use `npm run db:test`, `npm run test:integration`, and `npm run test:e2e`. `npm run db:reset` affects the local database only. ## Documentation -- [ADR 002](docs/architecture/adr-002-supabase-realtime.md) +- [Product scope](docs/product/scope.md) - [Data model](docs/architecture/data-model.md) +- [Realtime architecture decision](docs/architecture/adr-002-supabase-realtime.md) - [Realtime protocol](docs/architecture/realtime-protocol.md) - [Authorization and security](docs/security/authorization.md) - [Motion system](docs/design/motion-system.md) -- [Product scope](docs/product/scope.md) -- [Story 3 production validation](docs/releases/story-3-production-validation.md) +- [Production validation](docs/releases/story-3-production-validation.md) +- [v1.0.0 release notes](docs/releases/v1.0.0.md) + +## License + +No open-source license has been granted. The repository is presented as a portfolio project; all rights are reserved by Oniel Alejo Feliz. diff --git a/docs/architecture/adr-002-supabase-realtime.md b/docs/architecture/adr-002-supabase-realtime.md index a56cca7..9ce1957 100644 --- a/docs/architecture/adr-002-supabase-realtime.md +++ b/docs/architecture/adr-002-supabase-realtime.md @@ -1,6 +1,6 @@ # ADR 002: Supabase PostgreSQL and Realtime -**Status:** implemented locally; remote Free validation recorded in the pull request and final Story 2 report +**Status:** accepted and implemented in Story 2; production validated in Story 3 ## Context and requirements @@ -36,6 +36,6 @@ Every successful mutation increments `queues.revision` exactly once. Entry rows ## Free-plan constraints and risks -As verified on 2026-07-17, [Supabase Free pricing](https://supabase.com/pricing) lists two active projects, 500 MB database size, 5 GB egress, and pausing after roughly one week of low activity. [Realtime pricing](https://supabase.com/docs/guides/realtime/pricing) and [limits](https://supabase.com/docs/guides/realtime/limits) remain quota-bound. No keep-alive, paid add-on, trial, or public deployment is part of Story 2. +As verified on 2026-07-17, [Supabase Free pricing](https://supabase.com/pricing) listed two active projects, 500 MB database size, 5 GB egress, and pausing after roughly one week of low activity. [Realtime pricing](https://supabase.com/docs/guides/realtime/pricing) and [limits](https://supabase.com/docs/guides/realtime/limits) remain quota-bound and should be rechecked before operational use. No keep-alive, paid add-on, or trial is part of the project; Story 3 subsequently validated the public deployment on the existing Free project. Remaining hardening includes CAPTCHA or stronger anonymous abuse protection, cleanup of abandoned anonymous identities, capability rotation/recovery, more durable distributed attempt throttling, operational monitoring, data retention policy, and reassessment of Broadcast at scale. The current throttle is five failed attempts per user/queue/15-minute window with a 15-minute block; it is deliberately modest, not commercial brute-force protection. diff --git a/docs/architecture/realtime-evaluation.md b/docs/architecture/realtime-evaluation.md index 05129a1..b8c7f8d 100644 --- a/docs/architecture/realtime-evaluation.md +++ b/docs/architecture/realtime-evaluation.md @@ -1,6 +1,6 @@ # Real-time architecture evaluation -**Decision status:** proposed for Story 2 +**Decision status:** accepted and implemented in Story 2; production validated in Story 3 **Last verified against official documentation:** 2026-07-17 @@ -33,7 +33,7 @@ Spike **Supabase Postgres + Realtime** behind `QueueRealtimeAdapter` in Story 2. 5. Track a monotonic queue revision or `updated_at` plus stable event ordering so stale updates are discarded. 6. Exercise concurrent `call next`, network interruption, background-tab reconnect, free-project wake behavior, and quota exhaustion before any deployment decision. -Supabase is not installed and the adapter is not implemented in Story 1. This document is a recommendation, not a claim of production synchronization. +At the Story 1 decision point, Supabase was not installed and the adapter was not implemented. Story 2 subsequently implemented this recommendation, and Story 3 validated it in production. The comparison remains as the decision record rather than current implementation guidance. ## Cost controls @@ -47,7 +47,7 @@ Supabase is not installed and the adapter is not implemented in Story 1. This do ## Rejected assumptions and unresolved risks - Free tiers and beta capabilities change; the figures above are dated evidence, not a permanent guarantee. -- Supabase hosted Free projects may have wake/suspension behavior that harms an always-ready service counter. Current behavior must be measured in the Story 2 spike. +- Supabase hosted Free projects may have wake/suspension behavior that harms an always-ready service counter. Story 3 observed and documented the expected inactive-project pause and restore behavior. - Anonymous Version 1 staff control needs a safe capability model. With no authentication, a high-entropy staff capability or another narrow authorization scheme must prevent public clients from issuing staff commands without collecting personal data. - Postgres-change delivery is not itself a durable client event log. Reconnect must always resync a snapshot, and commands need idempotency keys. - The 200-connection free quota is sufficient for a portfolio demonstration and very small deployments, not broad production scale. diff --git a/docs/assets/screenshots/customer-status.png b/docs/assets/screenshots/customer-status.png new file mode 100644 index 0000000..6b70bb8 Binary files /dev/null and b/docs/assets/screenshots/customer-status.png differ diff --git a/docs/assets/screenshots/dark-theme.png b/docs/assets/screenshots/dark-theme.png new file mode 100644 index 0000000..05f28c7 Binary files /dev/null and b/docs/assets/screenshots/dark-theme.png differ diff --git a/docs/assets/screenshots/landing-page.png b/docs/assets/screenshots/landing-page.png new file mode 100644 index 0000000..70493e0 Binary files /dev/null and b/docs/assets/screenshots/landing-page.png differ diff --git a/docs/assets/screenshots/mobile-view.png b/docs/assets/screenshots/mobile-view.png new file mode 100644 index 0000000..233c564 Binary files /dev/null and b/docs/assets/screenshots/mobile-view.png differ diff --git a/docs/assets/screenshots/public-display.png b/docs/assets/screenshots/public-display.png new file mode 100644 index 0000000..00c772c Binary files /dev/null and b/docs/assets/screenshots/public-display.png differ diff --git a/docs/assets/screenshots/queue-creation.png b/docs/assets/screenshots/queue-creation.png new file mode 100644 index 0000000..035a67f Binary files /dev/null and b/docs/assets/screenshots/queue-creation.png differ diff --git a/docs/assets/screenshots/staff-board.png b/docs/assets/screenshots/staff-board.png new file mode 100644 index 0000000..897327a Binary files /dev/null and b/docs/assets/screenshots/staff-board.png differ diff --git a/docs/assets/video/next-v1-demo.mp4 b/docs/assets/video/next-v1-demo.mp4 new file mode 100644 index 0000000..039c737 Binary files /dev/null and b/docs/assets/video/next-v1-demo.mp4 differ diff --git a/docs/design/story-4/01-home.png b/docs/design/story-4/01-home.png new file mode 100644 index 0000000..103324b Binary files /dev/null and b/docs/design/story-4/01-home.png differ diff --git a/docs/design/story-4/02-demo-create.png b/docs/design/story-4/02-demo-create.png new file mode 100644 index 0000000..350904f Binary files /dev/null and b/docs/design/story-4/02-demo-create.png differ diff --git a/docs/design/story-4/03-customer-join.png b/docs/design/story-4/03-customer-join.png new file mode 100644 index 0000000..50b8bbf Binary files /dev/null and b/docs/design/story-4/03-customer-join.png differ diff --git a/docs/design/story-4/04-customer-status.png b/docs/design/story-4/04-customer-status.png new file mode 100644 index 0000000..02a5857 Binary files /dev/null and b/docs/design/story-4/04-customer-status.png differ diff --git a/docs/design/story-4/05-staff-board.png b/docs/design/story-4/05-staff-board.png new file mode 100644 index 0000000..d15ba44 Binary files /dev/null and b/docs/design/story-4/05-staff-board.png differ diff --git a/docs/design/story-4/06-public-display.png b/docs/design/story-4/06-public-display.png new file mode 100644 index 0000000..fedb962 Binary files /dev/null and b/docs/design/story-4/06-public-display.png differ diff --git a/docs/design/story-4/07-about.png b/docs/design/story-4/07-about.png new file mode 100644 index 0000000..252d58f Binary files /dev/null and b/docs/design/story-4/07-about.png differ diff --git a/docs/design/story-4/README.md b/docs/design/story-4/README.md new file mode 100644 index 0000000..ab6a6b2 --- /dev/null +++ b/docs/design/story-4/README.md @@ -0,0 +1,24 @@ +# Story 4 visual direction + +These seven boards document the approved ticket-led direction for the v1.0 +portfolio presentation. They are design references rather than screenshots of +the running application. + +The system follows the selected red-ticket reference: condensed black display +type, warm paper surfaces, cobalt information bands, visible rules, generous +queue numerals, functional iconography, and dense but legible operational +layouts. The same language is applied across marketing and product surfaces. + +| Board | View | +| ------------------------ | ---------------------------------------- | +| `01-home.png` | Landing page and product positioning | +| `02-demo-create.png` | Demo and queue creation | +| `03-customer-join.png` | Customer check-in before joining | +| `04-customer-status.png` | Customer ticket after joining | +| `05-staff-board.png` | Staff operations and waiting list | +| `06-public-display.png` | Distance-readable public display | +| `07-about.png` | Project principles and portfolio context | + +Production captures live under `docs/assets/screenshots/`; the silent walkthrough +lives under `docs/assets/video/`. Keeping references and captures separate makes +the design intent reviewable without presenting concept art as shipped UI. diff --git a/docs/product/scope.md b/docs/product/scope.md index c62fca2..4d0ff37 100644 --- a/docs/product/scope.md +++ b/docs/product/scope.md @@ -33,4 +33,4 @@ Permanent email/password or social accounts, organizations, teams, billing, subs Version 1 is done when all three surfaces are polished and understandable, domain invariants hold under concurrent commands, connected screens synchronize and recover, accessibility and narrow responsive QA pass, free-tier operation is verified without payment information, CI is green, production security is reviewed, and documentation does not overstate capabilities. -Story 1 satisfies the visual and domain foundation. Story 2 implements persistent PostgreSQL state, anonymous identity, transactional commands, database authorization, filtered Realtime invalidation, revisioned snapshots, reconnect convergence, and local multi-client validation. Public application deployment and production operations remain explicitly out of scope. +Story 1 established the visual and domain foundation. Story 2 implemented persistent PostgreSQL state, anonymous identity, transactional commands, database authorization, filtered Realtime invalidation, revisioned snapshots, reconnect convergence, and multi-client validation. Story 3 completed the reviewed production deployment and remote acceptance testing. Story 4 prepares the finished system for its public v1.0 portfolio release without expanding product scope. diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md new file mode 100644 index 0000000..01c5d59 --- /dev/null +++ b/docs/releases/v1.0.0.md @@ -0,0 +1,56 @@ +# v1.0.0 — Next Real-Time Queue System + +Next v1.0.0 is the first portfolio release of a production-deployed, persistent queue for small service teams. Customers, staff, and a public display stay synchronized through transactional PostgreSQL commands, filtered Supabase Realtime invalidation, and authoritative revisioned snapshots. + +## Highlights + +- Anonymous queue creation and customer check-in without permanent accounts or contact details +- Queue-scoped one-time staff capability with database-enforced membership +- Customer number, position, and turn status +- Staff call, complete, skip, pause, reopen, and close workflows +- Distance-readable public display with queue numbers only +- Responsive light/dark UI, reduced motion, keyboard support, and accessible live status +- Reconnect recovery and multi-client convergence without healthy-state polling + +## Reliability and security + +- All writes use explicit security-definer RPCs; browser roles have no direct table mutation grants. +- RLS covers all eight application tables, and only `queues` and display-safe `queue_entries` are published to Realtime. +- Row locks, state constraints, and a partial unique index preserve the one-serving invariant under concurrent commands. +- Request UUID receipts make retries idempotent and reject mismatched reuse. +- Staff codes are hashed, returned once, and excluded from URLs, logs, events, seeds, bundles, and retained release media. +- Optional customer names are separated from public queue state and visible only to the owner or authorized staff. + +## Validation + +- 34 Vitest unit/component checks, including 9 focused Realtime tests +- 62 pgTAP database checks +- 11 Supabase integration checks +- 15 Playwright application checks +- 9 read-only production smoke checks +- 131 passing automated checks in total without double-counting the focused Realtime subset +- Formatting, lint, typecheck, production build, database reset/lint/type generation, and dependency audit pass +- Production responsive, keyboard, focus, zoom, reduced-motion, dark-theme, privacy, reconnect, concurrency, and multi-client QA pass + +## Deployment + +- Production: https://next-queue-omega.vercel.app +- Vercel Hobby with Node.js 22.x +- One Supabase Free project in `us-east-1` +- No paid service, trial, custom domain, analytics, storage add-on, or payment method + +## Known limitations + +- Free-tier projects have finite capacity and Supabase may pause after inactivity. +- Anonymous identities cannot be recovered after browser data is cleared. +- A lost one-time staff code cannot be recovered without an authorized session. +- Enterprise abuse protection, monitoring, backup/recovery guarantees, automated retention, and a commercial SLA are outside v1.0. + +## Release checklist + +- [x] Story 3 production deployment merged and green +- [x] Recruiter-facing documentation and release media prepared +- [x] Synthetic release-capture data removed from application tables and Auth +- [ ] Story 4 pull request reviewed and merged +- [ ] Tag `v1.0.0` created from the final `main` commit +- [ ] GitHub release published with these notes and the demo video attached diff --git a/docs/security/authorization.md b/docs/security/authorization.md index 193112b..6cd250f 100644 --- a/docs/security/authorization.md +++ b/docs/security/authorization.md @@ -26,4 +26,4 @@ Every exposed function revokes public/`anon` execution and grants only `authenti ## Known limitations -Anonymous identity cannot be recovered after site-data clearing or transferred automatically to another device. Automatic cleanup of abandoned anonymous Supabase users is not built in. Capability rotation/recovery, stronger abuse prevention, operational alerting, formal retention/deletion policy, penetration testing, and a production SLA are outside Story 2. This project claims no security certification. +Anonymous identity cannot be recovered after site-data clearing or transferred automatically to another device. Automatic cleanup of abandoned anonymous Supabase users is not built in. Capability rotation/recovery, stronger abuse prevention, operational alerting, formal retention/deletion policy, penetration testing, and a production SLA are outside v1.0. This project claims no security certification. diff --git a/package-lock.json b/package-lock.json index c511223..bcf264a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "next-queue", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "next-queue", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { "@supabase/ssr": "0.12.3", "@supabase/supabase-js": "2.110.7", @@ -1607,9 +1607,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "dev": true, "license": "MIT", "funding": { @@ -1633,9 +1633,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", "cpu": [ "arm64" ], @@ -1650,9 +1650,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", "cpu": [ "arm64" ], @@ -1667,9 +1667,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", "cpu": [ "x64" ], @@ -1684,9 +1684,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", "cpu": [ "x64" ], @@ -1701,9 +1701,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", "cpu": [ "arm" ], @@ -1718,9 +1718,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", "cpu": [ "arm64" ], @@ -1735,9 +1735,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", "cpu": [ "arm64" ], @@ -1752,9 +1752,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", "cpu": [ "ppc64" ], @@ -1769,9 +1769,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", "cpu": [ "s390x" ], @@ -1786,9 +1786,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", "cpu": [ "x64" ], @@ -1803,9 +1803,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", "cpu": [ "x64" ], @@ -1820,9 +1820,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", "cpu": [ "arm64" ], @@ -1836,63 +1836,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", "cpu": [ "arm64" ], @@ -1907,9 +1854,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", "cpu": [ "x64" ], @@ -3667,9 +3614,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -7036,9 +6983,9 @@ } }, "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -7055,7 +7002,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7322,13 +7269,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7338,21 +7285,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" } }, "node_modules/run-parallel": { @@ -8304,9 +8250,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -8400,16 +8346,16 @@ } }, "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", + "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "bin": { @@ -8426,7 +8372,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8492,6 +8438,267 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", diff --git a/package.json b/package.json index ca49acf..ee72d29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-queue", - "version": "0.1.0", + "version": "1.0.0", "private": true, "scripts": { "dev": "next dev", @@ -58,7 +58,7 @@ }, "overrides": { "minimatch": "10.2.5", - "postcss": "8.5.19", + "postcss": "8.5.23", "sharp": "0.35.3" } } diff --git a/public/images/lifestyle/cafe-owner.webp b/public/images/lifestyle/cafe-owner.webp new file mode 100644 index 0000000..5376ca5 Binary files /dev/null and b/public/images/lifestyle/cafe-owner.webp differ diff --git a/public/images/lifestyle/public-display.webp b/public/images/lifestyle/public-display.webp new file mode 100644 index 0000000..3e0a5d0 Binary files /dev/null and b/public/images/lifestyle/public-display.webp differ diff --git a/public/images/lifestyle/service-handoff.webp b/public/images/lifestyle/service-handoff.webp new file mode 100644 index 0000000..17dcca4 Binary files /dev/null and b/public/images/lifestyle/service-handoff.webp differ diff --git a/public/images/lifestyle/staff-workflow.webp b/public/images/lifestyle/staff-workflow.webp new file mode 100644 index 0000000..7c9f8ce Binary files /dev/null and b/public/images/lifestyle/staff-workflow.webp differ diff --git a/public/images/lifestyle/wait-anywhere.webp b/public/images/lifestyle/wait-anywhere.webp new file mode 100644 index 0000000..d0cc080 Binary files /dev/null and b/public/images/lifestyle/wait-anywhere.webp differ diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index a7db824..a8e48e0 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,59 +1,95 @@ +import { + Accessibility, + BadgeDollarSign, + Network, + ShieldCheck, +} from 'lucide-react'; +import Image from 'next/image'; import { SiteFooter } from '@/components/site-footer'; import { productConfig } from '@/config/product'; +const principles = [ + { + eyebrow: 'Privacy', + title: 'Less data is better.', + icon: ShieldCheck, + description: + 'No email, phone number, password, analytics, advertising, or profiling. Optional first names stay private.', + }, + { + eyebrow: 'Technology', + title: 'One synchronized system.', + icon: Network, + description: + 'Next.js, PostgreSQL transactions, Row Level Security, and Realtime keep every view aligned.', + }, + { + eyebrow: 'Accessibility', + title: 'Clarity in every mode.', + icon: Accessibility, + description: + 'Keyboard operation, high contrast, large targets, calm announcements, and reduced-motion alternatives.', + }, + { + eyebrow: 'Cost', + title: 'Designed to remain $0.', + icon: BadgeDollarSign, + description: + 'Built and validated within practical free-tier limits, without paid add-ons or usage-based billing.', + }, +]; + export default function AboutPage() { return ( <> -
+

About the project

-

{productConfig.statement}

+

Clear queues. Less friction.

- Next is a focused portfolio project exploring how thoughtful - interface design can make a small, time-sensitive service system - feel clear and humane. + {productConfig.statement} Next explores how thoughtful interface + design can make a small, time-sensitive service system feel clear + and humane.

-
-
-

Privacy

-

Less data is better.

+
+
+ A North Star Café team member beside the NEXT staff board while service continues behind her. +
+
+

Made for real service

+

Technology should support the room.

- Anonymous browser identities require no email, phone number, - address, password, analytics, advertising, or profiling. A first - name is optional, isolated from public queue state, and visible - only to its owner and authorized staff. + NEXT keeps the operational work visible and the interface quiet, + so small teams can focus on the person in front of them.

-
-
-

Technology

-

Built around one small domain.

-

- Next.js, strict TypeScript, Supabase PostgreSQL, transactional RPC - commands, Row Level Security, Realtime invalidation, Motion, - Vitest, pgTAP, and Playwright form the engineering foundation. -

-
-
-

Accessibility

-

Clarity in every mode.

-

- Semantic landmarks, visible focus, keyboard operation, high - contrast, stable queue numerals, restrained live announcements, - large touch targets, and reduced-motion alternatives are product - requirements. -

-
-
-

Cost

-

Designed to remain $0.

-

- The local stack uses Docker and the Supabase CLI. Hosted - validation is limited to one Free project with no trial, payment - method, paid add-on, analytics, or application deployment. -

-
+
+ +
+ {principles.map( + ({ eyebrow, title, icon: Icon, description }, index) => ( +
+ +
+ ), + )}
+
+ Quiet technology. Clear progress. +
diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index 35cf7de..7d33b74 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -1,4 +1,14 @@ -import { ArrowRight, Monitor, Smartphone, Users } from 'lucide-react'; +import { + ArrowRight, + Eye, + Monitor, + Radio, + ShieldCheck, + Smartphone, + Users, + Wifi, +} from 'lucide-react'; +import Image from 'next/image'; import Link from 'next/link'; import { demoRoutes } from '@/config/product'; import { CreateQueuePanel } from '@/features/queue/create-queue-panel'; @@ -7,21 +17,26 @@ export const dynamic = 'force-dynamic'; const demos = [ { - title: 'Customer', - description: 'Join the queue and see a personal ticket and position.', + index: '01', + title: 'Customer ticket', + description: 'Join the seeded sample queue and see a personal ticket.', + action: 'Open customer view', href: demoRoutes.customer, icon: Smartphone, }, { - title: 'Staff', - description: - 'Claim access, then call, complete, skip, pause, reopen, or close.', + index: '02', + title: 'Staff board', + description: 'Call, complete, pause, and reopen the seeded sample queue.', + action: 'Open staff view', href: demoRoutes.staff, icon: Users, }, { - title: 'Display', - description: 'View the distance-readable public service board.', + index: '03', + title: 'Public display', + description: 'View the seeded sample queue on the public service board.', + action: 'Open display view', href: demoRoutes.display, icon: Monitor, }, @@ -29,43 +44,79 @@ const demos = [ export default function DemoPage() { return ( -
+
-

Story 2 persistent prototype

-

See the queue from every side.

+

Live product demo

+

One queue. Three points of view.

- Create a persistent queue, then open each synchronized surface in a - separate browser context. + Step into a working service flow as the customer, the team, or the + room—or issue a private queue of your own.

- -
- {demos.map(({ title, description, href, icon: Icon }) => ( - - - -

{title}

-

{description}

+
+
+ Staff member using the Next queue board while serving a customer +
+
+
+
+
+ {demos.map( + ({ index, title, description, action, href, icon: Icon }) => ( + + + + +
+

{title}

+

{description}

+
+ + {action} + + + ), + )} +
+ +
); } diff --git a/src/app/globals.css b/src/app/globals.css index e304564..021b082 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,16 +1,17 @@ @import 'tailwindcss'; :root { - --paper: #f2f0e9; - --paper-strong: #faf9f5; - --ink: #171815; - --muted: #686960; - --line: #cfcdc4; - --line-strong: #9e9d95; - --accent: #a63c24; + --paper: #f1e8d6; + --paper-strong: #fff9ec; + --ink: #11110f; + --muted: #625f57; + --line: #c9beaa; + --line-strong: #777168; + --accent: #a92b18; --accent-ink: #ffffff; - --accent-soft: #f0c9bc; - --positive: #28745d; + --accent-soft: #ffc2b4; + --signal-blue: #103f9c; + --positive: #167443; --warning: #986c1d; --focus: #1477d4; --shadow: 0 24px 70px rgb(34 33 28 / 10%); @@ -19,8 +20,8 @@ } .dark { - --paper: #131410; - --paper-strong: #1b1c17; + --paper: #15130f; + --paper-strong: #211e18; --ink: #f3f0e7; --muted: #aaa99f; --line: #393a33; @@ -28,6 +29,7 @@ --accent: #ff7957; --accent-ink: #17110d; --accent-soft: #703727; + --signal-blue: #315fc5; --positive: #73c7aa; --warning: #e5b961; --focus: #75baff; @@ -1060,3 +1062,877 @@ button { transition-duration: 0.01ms !important; } } + +/* Modern Ticket visual system */ + +.queue-page-shell { + min-height: 100svh; +} + +.queue-wordmark, +.display-type, +.queue-number, +.staff-active h2, +.ticket-join-area h2 { + font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif; + font-weight: 900; +} + +.queue-surface-header { + min-height: 112px; + display: flex; + align-items: stretch; + justify-content: space-between; + border: 2px solid var(--ink); + background: var(--paper-strong); +} + +.queue-surface-identity, +.queue-surface-actions { + display: flex; + align-items: center; +} + +.queue-surface-identity { + min-width: 0; + padding: 18px 28px; + gap: 26px; +} + +.queue-wordmark { + font-size: clamp(2.6rem, 5vw, 4.8rem); + line-height: 0.86; + letter-spacing: -0.045em; +} + +.queue-identity-rule { + align-self: stretch; + width: 2px; + background: var(--ink); +} + +.queue-surface-kicker { + display: block; + margin-bottom: 5px; + color: var(--muted); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.queue-venue-name { + display: block; + margin: 0; + max-width: 42vw; + overflow: hidden; + font-size: clamp(1.1rem, 2.2vw, 1.75rem); + line-height: 1.05; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-surface-actions { + padding: 18px 22px; + gap: 10px; + border-left: 2px solid var(--ink); +} + +.ticket-experience { + width: min(1480px, calc(100vw - 32px)); + margin: 16px auto 48px; +} + +.ticket-board { + display: grid; + grid-template-columns: minmax(0, 2.2fr) minmax(310px, 0.9fr); + grid-template-areas: + 'hero stats' + 'progress join' + 'guidance join'; + border: 2px solid var(--ink); + border-top: 0; + background: var(--paper-strong); +} + +.ticket-hero { + grid-area: hero; + min-height: 430px; + display: grid; + grid-template-columns: 168px minmax(0, 1fr); + margin: 28px; + overflow: hidden; + color: #11110f; + background: #f13a22; + border-radius: 28px; + position: relative; +} + +.ticket-stub { + padding: 30px 28px; + display: flex; + align-items: center; + justify-content: space-between; + border-right: 3px dashed #fff9ec; + font-family: 'Arial Narrow', Arial, sans-serif; + font-size: clamp(1rem, 2.2vw, 1.55rem); + font-weight: 900; + letter-spacing: 0.05em; + text-transform: uppercase; + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.ticket-stub span:last-child { + font-size: 2rem; +} + +.ticket-number-field { + min-width: 0; + display: grid; + place-items: center; + padding: 28px; + position: relative; +} + +.ticket-number { + max-width: 100%; + overflow: hidden; + font-size: clamp(6.5rem, 16vw, 15rem); + line-height: 0.8; + letter-spacing: 0; + text-overflow: clip; +} + +.ticket-cut, +.display-ticket-cut { + position: absolute; + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--paper-strong); +} + +.ticket-cut-left { + left: -24px; + top: calc(50% - 24px); +} + +.ticket-cut-right { + right: -24px; + top: calc(50% - 24px); +} + +.ticket-stats { + grid-area: stats; + display: grid; + grid-template-rows: 1.3fr 1.55fr 1fr 1fr; + border-left: 2px solid var(--ink); +} + +.ticket-stat { + min-height: 88px; + padding: 20px 28px; + display: flex; + align-items: center; + gap: 18px; + border-bottom: 2px solid var(--ink); +} + +.ticket-stat:last-child { + border-bottom: 0; +} + +.ticket-stat svg { + flex: 0 0 auto; + width: 30px; + height: 30px; + stroke-width: 1.7; +} + +.ticket-stat-position strong { + margin-left: auto; + font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif; + font-size: clamp(3.4rem, 7vw, 6.2rem); + line-height: 0.8; +} + +.ticket-stat-position { + flex-wrap: wrap; +} + +.ticket-stat-position small { + flex-basis: 100%; + margin-left: 48px; + color: var(--muted); + font-weight: 800; +} + +.ticket-stat-position span { + font-weight: 800; + line-height: 1.05; +} + +.ticket-stat-serving { + display: grid; + grid-template-columns: auto 1fr; +} + +.ticket-stat-serving span { + font-weight: 800; +} + +.ticket-stat-serving strong { + grid-column: 2; + color: var(--accent); + font-size: clamp(2.4rem, 5vw, 4.8rem); + line-height: 0.9; +} + +.motion-arrows { + color: var(--accent); + font-family: Arial, sans-serif; + font-size: 2.4rem; + font-weight: 900; + letter-spacing: -0.16em; +} + +.ticket-stat-motion strong, +.ticket-stat-live span { + font-size: 1.02rem; +} + +.ticket-stat-live svg { + color: var(--positive); +} + +.ticket-progress { + grid-area: progress; + display: grid; + grid-template-columns: repeat(3, 1fr); + padding: 28px clamp(32px, 6vw, 90px); + border-top: 2px solid var(--ink); + position: relative; +} + +.ticket-progress::before { + content: ''; + position: absolute; + left: 16%; + right: 16%; + top: 49px; + border-top: 3px dashed var(--signal-blue); +} + +.ticket-step { + z-index: 1; + display: grid; + justify-items: center; + gap: 4px; + text-align: center; +} + +.ticket-step-marker { + width: 46px; + height: 46px; + margin-bottom: 7px; + display: grid; + place-items: center; + border: 3px solid var(--signal-blue); + border-radius: 50%; + color: var(--signal-blue); + background: var(--paper-strong); +} + +.ticket-step-marker svg { + width: 20px; + height: 20px; + stroke-width: 2.5; +} + +.ticket-step.is-active .ticket-step-marker, +.ticket-step.is-complete .ticket-step-marker { + color: #fff; + background: var(--signal-blue); +} + +.ticket-step small { + color: var(--muted); +} + +.ticket-guidance { + grid-area: guidance; + min-height: 150px; + padding: 30px 40px; + display: flex; + align-items: center; + gap: 24px; + border-top: 2px solid var(--ink); + color: #fff; + background: var(--signal-blue); +} + +.ticket-guidance svg { + flex: 0 0 auto; + width: 52px; + height: 52px; + padding: 11px; + border: 2px solid currentColor; + border-radius: 50%; +} + +.ticket-guidance p { + margin: 0; + max-width: 660px; + font-size: clamp(1.25rem, 2.5vw, 2.15rem); + font-weight: 800; + line-height: 1.12; +} + +.ticket-join-area { + grid-area: join; + padding: 32px; + border-top: 2px solid var(--ink); + border-left: 2px solid var(--ink); +} + +.ticket-join-copy { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 28px; +} + +.ticket-join-area h2 { + margin: 0 0 14px; + font-size: clamp(2rem, 4vw, 3.6rem); + line-height: 0.95; + letter-spacing: -0.02em; + text-transform: uppercase; +} + +.ticket-join-area p { + color: var(--muted); +} + +.ticket-join-area .field-label { + margin-top: 0; +} + +.ticket-join-area .button { + width: 100%; + border-radius: 2px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ticket-saved-state { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 30px; +} + +.ticket-saved-state h2 { + margin: 0; +} + +.staff-experience .queue-surface-header { + border-bottom: 0; +} + +.staff-experience .setup-panel { + margin: 0; + max-width: none; + min-height: 620px; + border: 2px solid var(--ink); + display: grid; + align-content: center; +} + +.staff-experience .setup-panel > * { + width: min(680px, 100%); + margin-left: auto; + margin-right: auto; +} + +.staff-experience .staff-layout { + border: 2px solid var(--ink); +} + +.staff-experience .staff-active { + background: #f13a22; + color: #11110f; + border-right: 2px solid var(--ink); +} + +.staff-status-bar { + display: flex; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.staff-experience .staff-active .status-chip, +.staff-experience .staff-active .connection-state { + color: #11110f; + border-color: #11110f; +} + +.staff-experience .staff-active .eyebrow { + color: #11110f; +} + +.staff-experience .staff-active-number { + color: #11110f; + font-size: clamp(6rem, 15vw, 14rem); + letter-spacing: 0; +} + +.staff-experience .staff-active .button-accent { + color: var(--paper-strong); + background: #11110f; + border-color: #11110f; +} + +.staff-experience .staff-active .button-secondary { + color: #11110f; + border-color: #11110f; +} + +.staff-experience .staff-active .text-button { + color: #11110f; +} + +.staff-experience .staff-active .notice { + border-left-color: #11110f; + background: rgb(255 249 236 / 35%); +} + +.staff-experience .waiting-panel { + background: var(--paper-strong); +} + +.staff-experience .waiting-heading h2 { + font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif; + font-size: clamp(2rem, 4vw, 3.4rem); + text-transform: uppercase; +} + +.staff-experience .waiting-entry { + min-height: 92px; + border-color: var(--ink); +} + +.staff-experience .waiting-entry .queue-number { + color: var(--accent); + font-size: 1.8rem; + letter-spacing: 0; +} + +.display-page { + padding: 18px; + display: block; + overflow: auto; + background: var(--paper); + color: var(--ink); +} + +.display-top { + min-height: 112px; + padding: 18px 26px; + align-items: center; + border: 2px solid var(--ink); + background: var(--paper-strong); +} + +.display-identity, +.display-statuses { + display: flex; + align-items: center; +} + +.display-page .connection-state { + color: var(--positive); +} + +.display-identity { + gap: 24px; +} + +.display-identity > span { + align-self: stretch; + width: 2px; + background: var(--ink); +} + +.display-identity p { + margin: 0 0 3px; + color: var(--muted); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.display-identity h1 { + max-width: 50vw; + overflow: hidden; + font-size: clamp(1.3rem, 2.8vw, 2.3rem); + text-overflow: ellipsis; + white-space: nowrap; +} + +.display-statuses { + gap: 10px; +} + +.display-board { + min-height: calc(100svh - 148px); + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); + grid-template-areas: + 'active rail' + 'next guidance'; + grid-template-rows: minmax(430px, 1fr) auto; + border: 2px solid var(--ink); + border-top: 0; +} + +.display-active { + grid-area: active; + min-width: 0; + margin: 28px; + display: grid; + grid-template-columns: clamp(100px, 11vw, 170px) minmax(0, 1fr); + overflow: hidden; + position: relative; + color: #11110f; + background: #f13a22; + border-radius: 26px; +} + +.display-ticket-stub { + padding: 26px; + display: flex; + align-items: center; + justify-content: space-between; + border-right: 3px dashed #fff9ec; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.display-active-number { + align-self: center; + justify-self: center; + max-width: 100%; + overflow: hidden; + font-size: clamp(8rem, 24vw, 23rem); + line-height: 0.82; + letter-spacing: 0; +} + +.display-ticket-cut-left { + left: -24px; + top: calc(50% - 24px); +} + +.display-ticket-cut-right { + right: -24px; + top: calc(50% - 24px); +} + +.display-rail { + grid-area: rail; + display: grid; + grid-template-rows: 1.2fr 1fr; + border-left: 2px solid var(--ink); +} + +.display-rail > div { + padding: 30px; + display: flex; + align-items: center; + gap: 20px; + border-bottom: 2px solid var(--ink); +} + +.display-rail > div:first-child strong { + font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif; + font-size: clamp(4.5rem, 9vw, 8rem); + line-height: 0.8; +} + +.display-rail > div span:not(.motion-arrows) { + font-size: 1.2rem; + font-weight: 800; + line-height: 1.1; +} + +.display-up-next { + grid-area: next; + min-height: 155px; + padding: 26px 38px; + border-top: 2px solid var(--ink); + color: #fff; + background: var(--signal-blue); +} + +.display-up-next .quiet-label { + color: #fff; +} + +.display-up-next .next-numbers { + font-size: clamp(2.1rem, 4vw, 4rem); +} + +.display-up-next .queue-number { + letter-spacing: 0; +} + +.display-guidance { + grid-area: guidance; + padding: 26px; + display: flex; + align-items: center; + gap: 18px; + border-top: 2px solid var(--ink); + border-left: 2px solid var(--ink); +} + +.display-guidance span { + width: 44px; + height: 44px; + flex: 0 0 auto; + display: grid; + place-items: center; + border: 2px solid var(--ink); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 1.4rem; +} + +@media (max-width: 980px) { + .queue-surface-header { + flex-direction: column; + } + + .queue-surface-actions { + border-top: 2px solid var(--ink); + border-left: 0; + } + + .ticket-board { + grid-template-columns: 1fr; + grid-template-areas: + 'hero' + 'stats' + 'progress' + 'guidance' + 'join'; + } + + .ticket-stats { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: auto; + border-top: 2px solid var(--ink); + border-left: 0; + } + + .ticket-stat { + border-right: 2px solid var(--ink); + } + + .ticket-stat:nth-child(even) { + border-right: 0; + } + + .ticket-join-area { + border-left: 0; + } + + .display-board { + grid-template-columns: 1fr; + grid-template-areas: + 'active' + 'rail' + 'next' + 'guidance'; + } + + .display-rail { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: auto; + border-top: 2px solid var(--ink); + border-left: 0; + } + + .display-rail > div:first-child { + border-right: 2px solid var(--ink); + } + + .display-guidance { + border-left: 0; + } +} + +@media (max-width: 680px) { + .ticket-experience { + width: 100%; + margin: 0; + } + + .queue-surface-header, + .ticket-board, + .staff-experience .staff-layout, + .staff-experience .setup-panel { + border-right: 0; + border-left: 0; + } + + .queue-surface-identity { + padding: 18px 16px; + gap: 16px; + } + + .queue-wordmark { + font-size: 2.7rem; + } + + .queue-venue-name { + max-width: 55vw; + } + + .queue-surface-actions { + padding: 12px 16px; + flex-wrap: wrap; + } + + .ticket-hero { + min-height: 270px; + grid-template-columns: 62px minmax(0, 1fr); + margin: 16px; + border-radius: 18px; + } + + .ticket-stub { + padding: 15px 12px; + font-size: 0.75rem; + } + + .ticket-number-field { + padding: 18px; + } + + .ticket-number { + font-size: clamp(5rem, 25vw, 8rem); + } + + .ticket-stats { + grid-template-columns: 1fr; + } + + .ticket-stat, + .ticket-stat:nth-child(even) { + min-height: 86px; + padding: 18px 20px; + border-right: 0; + } + + .ticket-progress { + padding: 26px 12px; + } + + .ticket-progress::before { + left: 17%; + right: 17%; + top: 48px; + } + + .ticket-step small { + max-width: 82px; + font-size: 0.68rem; + } + + .ticket-guidance { + padding: 26px 20px; + } + + .ticket-guidance p { + font-size: 1.3rem; + } + + .ticket-join-area { + padding: 28px 20px; + } + + .staff-experience .staff-active, + .staff-experience .waiting-panel { + padding: 34px 20px; + } + + .display-page { + padding: 0; + } + + .display-top { + min-height: auto; + padding: 18px 16px; + flex-direction: column; + align-items: stretch; + border-right: 0; + border-left: 0; + } + + .display-identity { + gap: 14px; + } + + .display-identity h1 { + max-width: 58vw; + } + + .display-statuses { + flex-wrap: wrap; + } + + .display-board { + border-right: 0; + border-left: 0; + } + + .display-active { + min-height: 310px; + grid-template-columns: 60px minmax(0, 1fr); + margin: 16px; + border-radius: 18px; + } + + .display-ticket-stub { + padding: 12px; + font-size: 0.72rem; + } + + .display-active-number { + font-size: clamp(6rem, 30vw, 10rem); + } + + .display-rail { + grid-template-columns: 1fr; + } + + .display-rail > div:first-child { + border-right: 0; + } + + .display-up-next { + grid-template-columns: 1fr; + gap: 10px; + padding: 24px 20px; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e45bd8b..b103f76 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,24 @@ import type { Metadata } from 'next'; +import { Anton, Barlow_Condensed } from 'next/font/google'; import { productConfig } from '@/config/product'; import { SiteHeader } from '@/components/site-header'; import { ThemeProvider } from '@/components/theme-provider'; import './globals.css'; +import './ticket-theme.css'; + +const anton = Anton({ + weight: '400', + subsets: ['latin'], + variable: '--font-display', + display: 'swap', +}); + +const barlowCondensed = Barlow_Condensed({ + weight: ['400', '500', '600', '700', '800', '900'], + subsets: ['latin'], + variable: '--font-interface', + display: 'swap', +}); export const metadata: Metadata = { metadataBase: new URL(productConfig.siteUrl), @@ -23,7 +39,12 @@ export const metadata: Metadata = { export default function RootLayout({ children }: LayoutProps<'/'>) { return ( - + Skip to content diff --git a/src/app/page.tsx b/src/app/page.tsx index 0467ea8..628a381 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,16 @@ -import { ArrowRight, Eye, ShieldCheck, Wifi } from 'lucide-react'; +import { + ArrowRight, + Eye, + Info, + Monitor, + ShieldCheck, + Store, + UserRound, + Users, + Wifi, +} from 'lucide-react'; import Link from 'next/link'; +import Image from 'next/image'; import { LandingQueuePreview } from '@/components/landing-queue-preview'; import { SiteFooter } from '@/components/site-footer'; import { demoRoutes } from '@/config/product'; @@ -10,26 +21,38 @@ const surfaces = [ description: 'Join in seconds, then see your number and position without creating an account.', href: demoRoutes.customer, + icon: UserRound, + image: '/images/lifestyle/wait-anywhere.webp', + imageAlt: + 'A customer checking his place in line while waiting comfortably inside North Star Café.', }, { title: 'Staff queue board', description: 'One focused place to call, complete, skip, and pause the flow of service.', href: demoRoutes.staff, + icon: Users, + image: '/images/lifestyle/staff-workflow.webp', + imageAlt: + 'A café worker managing the NEXT staff board as a customer approaches the counter.', }, { title: 'Public display', description: 'A distance-readable view that makes the active number unmistakable.', href: demoRoutes.display, + icon: Monitor, + image: '/images/lifestyle/public-display.webp', + imageAlt: + 'The NEXT public queue display mounted above the service counter at North Star Café.', }, ]; export default function Home() { return ( <> -
-
+
+

Real-time queue management

@@ -48,11 +71,37 @@ export default function Home() {

- +
+ +
    + {surfaces.map(({ icon: Icon, ...surface }, index) => ( +
  1. + + {index + 1} +
  2. + ))} +
+
+
+ +
+
@@ -60,18 +109,57 @@ export default function Home() {

One queue · Three views

Everyone sees what matters now.

-
    - {surfaces.map((surface, index) => ( -
  1. - - 0{index + 1} +
    + {surfaces.map(({ icon: Icon, ...surface }, index) => ( +
    +
    + {surface.imageAlt} +
    +
    +
    + 0{index + 1} +

    {surface.title}

    {surface.description}

    -
  2. + + Explore this view +
+ +
+ +
+
+ A North Star Café worker handing a drink to a customer beneath the NEXT public display. +
+
+

From waiting to welcomed

+

+ A better handoff, not just a shorter line. +

+

+ Customers keep their time. Staff keep the room moving. The moment + of service stays human. +

+ + Experience the queue +
diff --git a/src/app/q/[slug]/page.tsx b/src/app/q/[slug]/page.tsx index 7209dfa..f935b43 100644 --- a/src/app/q/[slug]/page.tsx +++ b/src/app/q/[slug]/page.tsx @@ -1,4 +1,3 @@ -import { PrototypeHeader } from '@/components/prototype-header'; import { CustomerLive } from '@/features/queue/customer-live'; export const dynamic = 'force-dynamic'; @@ -7,11 +6,7 @@ export default async function CustomerPage({ params }: PageProps<'/q/[slug]'>) { const { slug } = await params; return ( -
- +
); diff --git a/src/app/q/[slug]/staff/page.tsx b/src/app/q/[slug]/staff/page.tsx index 9d868c7..e56e54e 100644 --- a/src/app/q/[slug]/staff/page.tsx +++ b/src/app/q/[slug]/staff/page.tsx @@ -1,4 +1,3 @@ -import { PrototypeHeader } from '@/components/prototype-header'; import { StaffLive } from '@/features/queue/staff-live'; export const dynamic = 'force-dynamic'; @@ -9,8 +8,7 @@ export default async function StaffPage({ const { slug } = await params; return ( -
- +
); diff --git a/src/app/ticket-theme.css b/src/app/ticket-theme.css new file mode 100644 index 0000000..e594f09 --- /dev/null +++ b/src/app/ticket-theme.css @@ -0,0 +1,3085 @@ +/* + * NEXT ticket system + * The shared visual language for marketing, customer, staff, and display views. + */ + +:root { + --paper: #f2e9dc; + --paper-strong: #fbf4e8; + --ink: #040404; + --muted: #5b554c; + --line: #040404; + --line-strong: #040404; + --accent: #dc2814; + --ticket-red: #e22811; + --accent-ink: #040404; + --accent-soft: #f6927d; + --signal-blue: #063d8f; + --positive: #0d792e; + --warning: #c07713; + --focus: #176cce; + --ticket-ink: #040404; + --ticket-paper: #f2e9dc; + --page: min(1600px, 100%); + --border: 2px solid var(--ink); + --display-font: var(--font-display), Impact, sans-serif; + --number-font: var(--font-display), Impact, sans-serif; + --body-font: var(--font-interface), 'Arial Narrow', Arial, sans-serif; + --shadow: none; + --radius-sm: 14px; + --radius-md: 22px; + --radius-lg: 30px; +} + +.dark { + --paper: #17140f; + --paper-strong: #231f18; + --ink: #f4ecdd; + --muted: #b9af9f; + --line: #f4ecdd; + --line-strong: #f4ecdd; + --accent: #ff745e; + --ticket-red: #ff4b32; + --accent-ink: #080806; + --accent-soft: #913c2d; + --signal-blue: #155bbb; + --positive: #57bd73; + --warning: #e3a64f; + --focus: #89bdff; + --ticket-paper: #f5ecdc; +} + +html { + background: var(--paper); +} + +body { + min-height: 100svh; + background-color: var(--paper); + background-image: + radial-gradient(circle at 12% 5%, rgb(255 255 255 / 72%), transparent 34%), + radial-gradient(circle at 86% 72%, rgb(143 92 39 / 8%), transparent 38%); + background-attachment: fixed; + font-family: var(--body-font); + font-size: 1.05rem; + font-weight: 500; +} + +.dark body { + background-image: + radial-gradient(circle at 12% 5%, rgb(255 244 222 / 7%), transparent 34%), + radial-gradient(circle at 86% 72%, rgb(0 0 0 / 20%), transparent 38%); +} + +.display-type, +.brand, +.queue-wordmark, +.queue-venue-name, +.demo-intro h1, +.about-intro h1, +.section-heading h2, +.surface-index, +.queue-number, +.ticket-number, +.staff-active h2, +.waiting-heading h2, +.display-active-number, +.about-manifesto { + font-family: var(--display-font); + font-weight: 400; + font-stretch: condensed; +} + +.ticket-join-area h2, +.ticket-guidance p, +.staff-experience > .setup-panel h2, +.demo-page .setup-panel h2, +.about-block h2, +.home-signal-band h2, +.display-guidance strong { + font-family: var(--display-font); + font-weight: 400; + font-stretch: condensed; +} + +.brand, +.queue-wordmark, +.surface-index, +.queue-number, +.ticket-number, +.staff-active-number, +.display-active-number { + font-family: var(--number-font); +} + +.queue-preview, +.about-intro::after, +.ticket-hero, +.staff-active, +.staff-experience > .setup-panel, +.display-active { + background-color: var(--ticket-red); + background-image: + radial-gradient(circle at 52% 3%, rgb(255 120 92 / 35%), transparent 38%), + linear-gradient(155deg, #f4321c 0%, #e72712 52%, #d91d0d 100%); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 25%), + inset 0 -38px 90px rgb(104 0 0 / 10%); +} + +.page-shell { + width: var(--page); +} + +.site-header { + width: var(--page); + min-height: 126px; + margin-top: 0; + padding: 0 30px; + border: var(--border); + background: var(--paper); +} + +.brand { + align-self: stretch; + display: grid; + place-items: center; + padding-right: 36px; + border-right: var(--border); + font-size: clamp(3.5rem, 6vw, 5.75rem); + line-height: 0.78; + letter-spacing: -0.01em; +} + +.desktop-nav { + gap: 0; + margin-left: auto; +} + +.desktop-nav a { + min-height: 52px; + display: inline-grid; + place-items: center; + padding: 0 30px; + border-right: 1px solid var(--ink); + color: var(--ink); + font-family: var(--display-font); + font-size: 1.18rem; + font-weight: 700; + text-decoration: none; +} + +.desktop-nav a[aria-current='page'] { + color: var(--accent); + box-shadow: inset 0 -4px 0 var(--accent); +} + +.desktop-nav a:hover { + background: var(--paper-strong); + color: var(--accent); + text-decoration: none; +} + +.header-actions { + gap: 0; +} + +.icon-button { + width: 52px; + height: 52px; + border: 0; + border-radius: 0; + background: transparent; +} + +.icon-button:hover { + background: var(--paper-strong); +} + +.eyebrow, +.quiet-label, +.queue-surface-kicker, +.display-identity p { + color: currentColor; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.button { + min-height: 58px; + padding: 0 24px; + border: var(--border); + border-radius: 999px; + box-shadow: none; + font-weight: 800; +} + +.button-accent { + background: var(--ink); + color: var(--paper); +} + +.button-secondary { + background: transparent; + color: inherit; +} + +.button:hover:not(:disabled) { + transform: translateY(-2px); +} + +.text-input { + min-height: 58px; + border: var(--border); + border-radius: var(--radius-sm); + background: var(--paper-strong); + color: var(--ink); + font-size: 1.06rem; +} + +.text-button { + color: inherit; + font-weight: 800; +} + +.status-chip, +.connection-state { + min-height: 42px; + display: inline-flex; + align-items: center; + gap: 9px; + padding: 0 14px; + border: 1px solid currentColor; + border-radius: 8px; + color: inherit; + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.status-dot { + width: 9px; + height: 9px; + background: var(--positive); +} + +/* Home */ + +.home-page { + border-right: var(--border); + border-left: var(--border); +} + +.home-hero { + min-height: min(675px, calc(100svh - 142px)); + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: 0; + align-items: stretch; + margin: 0; + padding: 0; + border-bottom: var(--border); +} + +.home-hero .hero-copy { + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: clamp(34px, 5vw, 78px) clamp(28px, 4vw, 64px); + border-right: var(--border); +} + +.home-hero .display-type { + max-width: 760px; + margin: 0; + font-size: clamp(4.7rem, 6.6vw, 7rem); + line-height: 0.9; + letter-spacing: -0.04em; +} + +.home-hero .lede { + max-width: 560px; + margin: 28px 0 0; + color: var(--ink); + font-size: clamp(1.02rem, 1.6vw, 1.35rem); +} + +.home-hero .hero-actions { + margin-top: 30px; +} + +.home-hero .button-accent { + background: transparent; + color: var(--accent); + border-color: var(--accent); +} + +.home-product-preview { + min-width: 0; + display: grid; + grid-template-rows: 1fr auto; +} + +.queue-preview { + position: relative; + min-height: 390px; + height: 390px; + width: auto; + margin: clamp(28px, 4vw, 48px); + padding: clamp(26px, 3vw, 44px); + overflow: hidden; + border: 0; + border-radius: 30px; + background: var(--ticket-red); + color: var(--ticket-ink); + box-shadow: none; +} + +.queue-preview::before, +.queue-preview::after { + content: ''; + position: absolute; + z-index: 2; + top: 50%; + width: 52px; + height: 52px; + border-radius: 50%; + background: var(--paper); + transform: translateY(-50%); +} + +.queue-preview::before { + left: -27px; +} + +.queue-preview::after { + right: -27px; +} + +.preview-topline, +.preview-bottomline { + position: relative; + z-index: 3; + border-color: var(--ticket-ink); + color: var(--ticket-ink); + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.queue-preview .eyebrow { + margin-top: 32px; + color: var(--ticket-ink); +} + +.preview-number { + position: relative; + z-index: 3; + display: block; + font-size: clamp(7rem, 17vw, 15.5rem); + line-height: 0.82; + letter-spacing: -0.055em; + color: var(--ticket-ink); + white-space: nowrap; +} + +.home-surface-list { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-top: var(--border); +} + +.home-surface-list li { + border-right: var(--border); +} + +.home-surface-list li:last-child { + border-right: 0; +} + +.home-surface-list .surface-row { + min-height: 132px; + display: grid; + grid-template-columns: auto auto 1fr auto; + align-items: center; + gap: 16px; + padding: 18px 22px; + border: 0; +} + +.home-surface-list .surface-row:hover { + background: var(--paper-strong); +} + +.home-surface-list .surface-index { + color: var(--ink); + font-size: 3.8rem; + line-height: 1; +} + +.home-surface-list h3 { + margin: 0; + font-size: 1rem; +} + +.home-surface-list svg { + width: 34px; + height: 34px; +} + +.home-signal-band { + min-height: 190px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 38px; + padding: 30px 50px; + border-bottom: var(--border); + background-color: var(--signal-blue); + background-image: + radial-gradient(circle at 38% 0%, rgb(24 111 211 / 50%), transparent 46%), + linear-gradient(155deg, #0752a8 0%, #063d8f 58%, #032f79 100%); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 16%); + color: #fff8ea; +} + +.home-signal-band > svg:first-child { + width: 68px; + height: 68px; + padding-right: 24px; + border-right: 1px solid currentColor; +} + +.home-signal-band h2, +.home-signal-band p { + margin: 0; +} + +.home-signal-band h2 { + font-family: var(--display-font); + font-size: clamp(2rem, 3vw, 3.6rem); + line-height: 1; +} + +.home-signal-band p { + margin-top: 10px; + font-size: 1.1rem; +} + +.home-store-icon { + width: 128px; + height: 94px; +} + +.home-detail-section, +.home-page > .section { + margin: 0; + padding: clamp(54px, 7vw, 96px) clamp(28px, 5vw, 72px); + border-bottom: var(--border); +} + +.surface-detail-grid, +.principles-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-top: var(--border); + border-left: var(--border); + gap: 0; + background: transparent; +} + +.surface-detail-grid article, +.principle { + min-height: 260px; + padding: 30px; + border-right: var(--border); + border-bottom: var(--border); + border-radius: 0; + background: transparent; +} + +.surface-detail-grid article { + min-width: 0; + padding: 0; + overflow: hidden; +} + +.surface-detail-media { + position: relative; + aspect-ratio: 4 / 3; + overflow: hidden; + border-bottom: var(--border); + background: var(--ink); +} + +.surface-detail-media img, +.home-outcome-media img, +.about-human-media img { + object-fit: cover; + transition: transform 500ms cubic-bezier(0.2, 0.75, 0.25, 1); +} + +.surface-detail-grid article:hover .surface-detail-media img { + transform: scale(1.025); +} + +.surface-detail-copy { + min-height: 290px; + display: flex; + flex-direction: column; + padding: 28px 30px 30px; +} + +.surface-detail-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 22px; +} + +.surface-detail-heading svg { + width: 38px; + height: 38px; + stroke-width: 1.5; +} + +.surface-detail-grid .surface-index { + display: block; + margin-bottom: 0; + color: var(--accent); + font-size: 3rem; +} + +.surface-detail-grid h3, +.principle h3 { + font-size: 1.35rem; +} + +.surface-detail-copy > p { + margin-bottom: 30px; +} + +.surface-detail-link { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: auto; + color: var(--ink); + font-weight: 800; + text-decoration: none; +} + +.surface-detail-link:hover { + color: var(--accent); +} + +.home-outcome-story { + min-height: 560px; + display: grid; + grid-template-columns: minmax(0, 1.75fr) minmax(360px, 0.75fr); + border-bottom: var(--border); +} + +.home-outcome-media { + position: relative; + min-height: 560px; + overflow: hidden; + border-right: var(--border); + background: var(--ink); +} + +.home-outcome-media img { + object-position: center center; +} + +.home-outcome-copy { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + padding: clamp(38px, 4vw, 64px); + overflow: hidden; + background-color: var(--ticket-red); + background-image: + radial-gradient(circle at 52% 3%, rgb(255 120 92 / 35%), transparent 38%), + linear-gradient(155deg, #f4321c 0%, #e72712 52%, #d91d0d 100%); + color: var(--ticket-ink); +} + +.home-outcome-copy::before, +.home-outcome-copy::after { + content: ''; + position: absolute; + left: -27px; + width: 54px; + height: 54px; + border-radius: 50%; + background: var(--paper); +} + +.home-outcome-copy::before { + top: 24%; +} + +.home-outcome-copy::after { + bottom: 24%; +} + +.home-outcome-copy h2 { + max-width: 470px; + margin: 14px 0 24px; + font-family: var(--display-font); + font-size: clamp(3.5rem, 4.6vw, 5.3rem); + font-weight: 400; + line-height: 0.92; + letter-spacing: -0.035em; +} + +.home-outcome-copy > p:not(.eyebrow) { + max-width: 420px; + margin: 0 0 32px; + font-size: 1.18rem; + line-height: 1.35; +} + +.home-outcome-copy .button-accent { + background: var(--ticket-ink); + color: var(--ticket-paper); +} + +/* Demo and queue setup */ + +.demo-page { + min-height: calc(100svh - 126px); + display: grid; + grid-template-columns: minmax(0, 1.14fr) minmax(470px, 0.86fr); + grid-template-rows: auto 1fr auto; + border-right: var(--border); + border-left: var(--border); +} + +.demo-intro { + grid-column: 1; + grid-row: 1; + margin: 0; + padding: clamp(34px, 3.4vw, 52px) clamp(28px, 3.4vw, 54px) 28px; +} + +.demo-intro h1 { + max-width: 760px; + margin: 0; + font-size: clamp(4rem, 5.2vw, 5.5rem); + line-height: 0.9; + letter-spacing: -0.04em; +} + +.demo-intro .lede { + max-width: 620px; + color: var(--ink); +} + +.demo-page > .setup-panel { + position: relative; + grid-column: 2; + grid-row: 1 / span 2; + align-self: stretch; + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + margin: 28px 28px 28px 0; + padding: 0; + overflow: hidden; + border: var(--border); + border-radius: var(--radius-lg); + background: var(--paper-strong); + color: var(--ink); +} + +.demo-page > .setup-panel::before, +.demo-page > .setup-panel::after { + content: ''; + position: absolute; + top: 50%; + width: 54px; + height: 54px; + border-radius: 50%; + background: var(--paper); + transform: translateY(-50%); +} + +.demo-page > .setup-panel::before { + left: -28px; +} + +.demo-page > .setup-panel::after { + right: -28px; +} + +.setup-ticket-stub { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + padding: 28px 0; + border-right: 3px dashed var(--paper-strong); + background: var(--ticket-red); + color: var(--ticket-ink); + font-family: var(--display-font); + font-size: 1.35rem; + text-transform: uppercase; + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.setup-panel-content { + min-width: 0; + min-height: 100%; + display: flex; + flex-direction: column; + padding: clamp(32px, 3vw, 48px) clamp(34px, 3.4vw, 54px); + background: var(--paper-strong); +} + +.demo-page .setup-panel .eyebrow { + color: var(--ticket-ink); +} + +.demo-page .setup-panel h2 { + max-width: 560px; + margin: 0 0 16px; + font-family: var(--display-font); + font-size: clamp(3.1rem, 4vw, 4.7rem); + line-height: 0.9; +} + +.setup-panel-intro { + max-width: 520px; + margin: 0 0 28px; + font-size: 1.05rem; + font-weight: 700; + line-height: 1.35; +} + +.demo-page .setup-form { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) 112px; + grid-template-areas: + 'name-label prefix-label' + 'name-input prefix-input' + 'submit submit'; + gap: 10px 14px; +} + +.demo-page .setup-form label:first-of-type { + grid-area: name-label; +} + +.demo-page .setup-form label:nth-of-type(2) { + grid-area: prefix-label; +} + +.demo-page .setup-form input:first-of-type { + grid-area: name-input; +} + +.demo-page .setup-form input:nth-of-type(2) { + grid-area: prefix-input; + text-align: center; + text-transform: uppercase; +} + +.demo-page .setup-form .text-input { + max-width: none; + background: var(--ticket-paper); + color: var(--ticket-ink); +} + +.demo-page .setup-form .button { + grid-area: submit; + margin-top: 12px; + background: var(--signal-blue); + color: #fff8ea; + border-color: var(--signal-blue); +} + +.demo-page .notice { + position: relative; + z-index: 1; + border-color: var(--ticket-ink); + background: var(--paper); + color: var(--ink); + margin-top: 18px; + font-size: 0.84rem; +} + +.setup-deliverables { + margin-top: auto; + padding-top: 30px; +} + +.setup-deliverables > .eyebrow { + margin-bottom: 12px; +} + +.setup-deliverables ul { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.setup-deliverables li { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + align-items: center; + gap: 14px; + padding: 12px 16px; + border: 1px solid var(--ink); + border-radius: var(--radius-sm); + background: var(--paper); +} + +.setup-deliverables li > span { + color: var(--accent); + font-family: var(--display-font); + font-size: 1.8rem; + line-height: 1; +} + +.setup-deliverables li > div { + display: grid; + gap: 2px; +} + +.setup-deliverables strong { + font-size: 0.95rem; +} + +.setup-deliverables small { + color: var(--muted); + font-size: 0.78rem; +} + +.demo-showcase { + grid-column: 1; + grid-row: 2; + min-width: 0; + margin: 0 36px 28px; + border: var(--border); + border-radius: var(--radius-lg); + background: var(--paper); + overflow: hidden; +} + +.demo-showcase-media { + position: relative; + min-height: 260px; + overflow: hidden; + border-bottom: var(--border); +} + +.demo-showcase-media img { + object-fit: cover; + object-position: center 48%; + filter: saturate(0.92) contrast(1.03); +} + +.demo-live-label { + position: absolute; + right: 18px; + bottom: 18px; + display: flex; + align-items: center; + gap: 10px; + padding: 11px 15px; + border: 2px solid var(--ticket-paper); + background: rgb(4 4 4 / 86%); + color: var(--ticket-paper); + backdrop-filter: blur(10px); +} + +.demo-live-label > svg { + color: #71d88f; +} + +.demo-live-label span { + display: grid; + font-size: 0.72rem; + line-height: 1.2; + text-transform: uppercase; +} + +.demo-live-label strong { + font-size: 0.95rem; +} + +.demo-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + padding: 14px; +} + +.demo-card { + min-height: 214px; + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr auto; + gap: 18px 14px; + padding: 20px; + border: 1px solid var(--ink); + border-radius: var(--radius-md); + background: var(--paper-strong); + color: var(--ink); +} + +.demo-card:last-child { + border-right: 1px solid var(--ink); +} + +.demo-card:nth-child(1) { + box-shadow: inset 0 8px 0 var(--ticket-red); + background: var(--paper-strong); + color: var(--ink); +} + +.demo-card:nth-child(2) { + box-shadow: inset 0 8px 0 var(--signal-blue); + background: var(--paper-strong); + color: var(--ink); +} + +.demo-card:nth-child(3) { + box-shadow: inset 0 8px 0 var(--ink); +} + +.demo-card:hover { + transform: none; + filter: brightness(1.04); +} + +.demo-card-icon { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-color: currentColor; + border-radius: 50%; +} + +.demo-card h2 { + margin: 0 0 8px; + font-family: var(--display-font); + font-size: 1.75rem; + line-height: 0.95; +} + +.demo-card p { + display: block; + margin: 0; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.35; +} + +.demo-card-index { + font-family: var(--display-font); + font-size: 2.5rem; + line-height: 0.85; + color: var(--ticket-red); +} + +.demo-card:nth-child(2) .demo-card-index { + color: var(--signal-blue); +} + +.demo-card:nth-child(3) .demo-card-index { + color: var(--ink); +} + +.demo-card-action { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 14px; + border-top: 1px solid currentColor; + font-size: 0.82rem; + font-weight: 900; + text-transform: uppercase; +} + +.demo-note { + grid-column: 1 / -1; + grid-row: 3; + min-height: 132px; + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: center; + gap: 0; + margin: 0; + padding: 0 32px; + border: 0; + border-top: var(--border); + border-radius: 0; + background-color: var(--signal-blue); + background-image: + radial-gradient(circle at 38% 0%, rgb(24 111 211 / 50%), transparent 46%), + linear-gradient(155deg, #0752a8 0%, #063d8f 58%, #032f79 100%); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 16%); + color: #fff8ea; + font-size: 0.96rem; + width: auto; + max-width: none; + margin: 0; +} + +.demo-proof { + min-width: 0; + min-height: 82px; + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + align-items: center; + gap: 18px; + padding: 12px 28px; + border-right: 1px solid currentColor; +} + +.demo-proof:last-child { + border-right: 0; +} + +.demo-proof > svg { + width: 50px; + height: 50px; + padding: 10px; + border: 2px solid currentColor; + border-radius: 50%; +} + +.demo-proof span { + min-width: 0; + line-height: 1.35; +} + +.demo-proof strong { + display: block; + margin-bottom: 4px; + font-family: var(--display-font); + font-size: 1.45rem; + line-height: 1; +} + +.code-reveal { + position: relative; + z-index: 3; + background: var(--ticket-paper); + color: var(--ticket-ink); +} + +.created-queue-actions { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.created-queue-actions .button { + width: 100%; + justify-content: center; +} + +.created-queue-actions .button-accent { + background: var(--ticket-ink); + color: var(--ticket-paper); +} + +.created-queue-actions .button-secondary { + border-color: var(--ticket-ink); + background: transparent; + color: var(--ticket-ink); +} + +/* About */ + +.about-page { + border-right: var(--border); + border-left: var(--border); +} + +.about-intro { + position: relative; + width: 100%; + max-width: none; + margin: 0; + padding: 34px clamp(30px, 3vw, 48px) 28px; + border-bottom: var(--border); +} + +.about-intro::after { + content: ''; + position: absolute; + top: 20px; + right: 26px; + width: 208px; + height: calc(100% - 40px); + border-radius: 24px; + background: var(--ticket-red); + clip-path: polygon( + 0 0, + 100% 0, + 100% 40%, + 84% 50%, + 100% 60%, + 100% 100%, + 0 100% + ); +} + +.about-intro h1 { + position: relative; + z-index: 1; + max-width: calc(100% - 230px); + margin: 0; + font-size: clamp(5rem, 5.6vw, 5.6rem); + line-height: 0.9; + letter-spacing: -0.04em; + white-space: nowrap; +} + +.about-intro .lede { + position: relative; + z-index: 1; + color: var(--ink); + max-width: 860px; + margin-top: 14px; + font-size: 1rem; +} + +.about-human-story { + min-height: 480px; + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(360px, 0.65fr); + border-bottom: var(--border); +} + +.about-human-media { + position: relative; + min-height: 480px; + overflow: hidden; + border-right: var(--border); + background: var(--ink); +} + +.about-human-media img { + object-position: center 42%; +} + +.about-human-copy { + display: flex; + flex-direction: column; + justify-content: center; + padding: clamp(36px, 4vw, 64px); + background-color: var(--signal-blue); + background-image: + radial-gradient(circle at 38% 0%, rgb(24 111 211 / 50%), transparent 46%), + linear-gradient(155deg, #0752a8 0%, #063d8f 58%, #032f79 100%); + color: #fff8ea; +} + +.about-human-copy h2 { + margin: 14px 0 24px; + font-family: var(--display-font); + font-size: clamp(3.4rem, 4.4vw, 5rem); + font-weight: 400; + line-height: 0.92; + letter-spacing: -0.035em; +} + +.about-human-copy > p:last-child { + max-width: 430px; + margin: 0; + font-size: 1.18rem; + line-height: 1.4; +} + +.about-columns { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0; + margin-bottom: 0; + border: 0; + background: transparent; +} + +.about-block { + min-height: 208px; + display: grid; + grid-template-columns: 92px 72px minmax(0, 1fr); + align-items: center; + gap: 24px; + padding: 26px 38px; + border: 0; + border-right: var(--border); + border-bottom: var(--border); + border-radius: 0; +} + +.about-block:nth-child(even) { + border-right: 0; +} + +.about-block h2 { + font-family: var(--display-font); + margin: 4px 0 8px; + font-size: clamp(1.8rem, 2.8vw, 2.8rem); + line-height: 1; +} + +.about-block p { + max-width: 620px; + color: var(--ink); + margin: 0; + line-height: 1.45; +} + +.about-index { + align-self: stretch; + display: grid; + place-items: center; + border-right: 1px solid var(--ink); + font-family: var(--number-font); + font-size: 4.5rem; + line-height: 1; +} + +.about-icon { + width: 62px; + height: 62px; + stroke-width: 1.4; +} + +.about-manifesto { + min-height: 150px; + display: flex; + align-items: center; + padding: 24px 42px; + background: var(--signal-blue); + color: #fff8ea; + font-size: clamp(3.8rem, 4.8vw, 5rem); + line-height: 0.95; + letter-spacing: -0.045em; +} + +/* Shared queue surfaces */ + +.queue-page-shell { + min-height: 100svh; + padding: 0; +} + +.ticket-experience, +.staff-experience { + width: 100%; + min-height: 100svh; + margin: 0; + overflow: hidden; + border: var(--border); + background: var(--paper); +} + +.queue-surface-header { + min-height: 142px; + padding: 0 34px; + border: 0; + border-bottom: var(--border); + background: var(--paper); +} + +.queue-surface-identity { + min-width: 0; + align-self: stretch; + gap: 34px; +} + +.queue-wordmark { + display: grid; + place-items: center; + align-self: stretch; + padding-right: 34px; + font-size: clamp(3.8rem, 6.4vw, 6.2rem); + line-height: 0.75; + letter-spacing: -0.01em; +} + +.queue-identity-rule { + align-self: stretch; + width: 2px; + height: auto; + background: var(--ink); +} + +.queue-venue-name { + max-width: 50vw; + overflow: hidden; + font-size: clamp(2rem, 3.6vw, 4rem); + line-height: 1; + letter-spacing: -0.035em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-surface-actions { + gap: 20px; +} + +.queue-surface-actions .icon-button { + border-left: 1px solid var(--ink); +} + +.live-loading, +.prototype-shell { + min-height: calc(100svh - 32px); + display: grid; + place-items: center; + padding: 40px; + border: var(--border); + background: var(--paper); + font-family: var(--display-font); + font-size: clamp(2rem, 5vw, 5rem); + text-align: center; +} + +/* Customer ticket */ + +.ticket-board { + width: 100%; + max-width: 100vw; + min-height: calc(100svh - 142px); + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(340px, 0.82fr); + grid-template-areas: + 'hero stats' + 'progress stats' + 'guidance join'; + grid-template-rows: minmax(390px, 1fr) auto minmax(160px, auto); + background: var(--paper); +} + +.ticket-issued .ticket-board { + height: calc(100svh - 142px); + min-height: 0; + grid-template-rows: minmax(360px, 1fr) 170px 160px; +} + +.ticket-issued .ticket-stub span:first-child { + font-size: clamp(1.9rem, 2.5vw, 2.5rem); +} + +.ticket-issued .ticket-join-area { + padding: 16px 28px; +} + +.ticket-issued .ticket-saved-state { + justify-content: center; + gap: 6px; +} + +.ticket-issued .ticket-join-area h2 { + font-size: clamp(2rem, 2.2vw, 2.25rem); +} + +.ticket-issued .ticket-saved-state p { + font-size: 0.9rem; + line-height: 1.3; +} + +.ticket-hero { + grid-area: hero; + min-width: 0; + max-width: calc(100% - 76px); + min-height: 0; + margin: 30px 38px 20px; + overflow: hidden; + border-radius: 28px; + background-color: var(--ticket-red); + background-image: + radial-gradient(circle at 52% 3%, rgb(255 120 92 / 35%), transparent 38%), + linear-gradient(155deg, #f4321c 0%, #e72712 52%, #d91d0d 100%); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 25%), + inset 0 -38px 90px rgb(104 0 0 / 10%); + color: var(--ticket-ink); +} + +.ticket-stub { + width: 168px; + min-width: 168px; + padding: 28px 0; + border-right: 3px dashed #fff8ea; + color: var(--ticket-ink); + writing-mode: horizontal-tb; + transform: none; +} + +.ticket-stub span:first-child { + font-family: var(--display-font); + font-size: clamp(2rem, 3vw, 3.6rem); + line-height: 1; + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.ticket-number-field { + width: 100%; + min-width: 0; + overflow: hidden; +} + +.ticket-number { + display: block; + width: 100%; + max-width: 100%; + overflow: hidden; + font-size: clamp(7rem, 14vw, 15rem); + line-height: 0.78; + letter-spacing: -0.045em; + color: var(--ticket-ink); + text-align: center; + text-overflow: clip; + white-space: nowrap; +} + +.ticket-cut, +.staff-ticket-cut, +.display-ticket-cut { + background: var(--paper); +} + +.ticket-stats { + grid-area: stats; + min-width: 0; + display: grid; + grid-template-rows: 1.25fr 1.3fr 0.82fr 0.68fr 0.68fr; + border-left: var(--border); + background: var(--paper-strong); +} + +.ticket-stat { + min-width: 0; + padding: 18px clamp(22px, 2.2vw, 34px); + border-bottom: 1px solid var(--ink); +} + +.ticket-stat-position { + grid-template-columns: auto auto 1fr; + background: + linear-gradient(90deg, rgb(6 61 143 / 7%), transparent 58%), + var(--paper-strong); +} + +.ticket-stat-position strong { + font-family: var(--display-font); + font-size: clamp(4rem, 7vw, 7.6rem); +} + +.ticket-stat-serving strong { + color: var(--accent); + font-size: clamp(3.5rem, 4.8vw, 5.8rem); + letter-spacing: -0.035em; +} + +.ticket-stat-position small { + font-weight: 800; +} + +.ticket-stat-motion { + background: rgb(226 40 17 / 5%); +} + +.ticket-stat-motion strong { + font-family: var(--display-font); + font-size: 1.25rem; + letter-spacing: 0.01em; +} + +.ticket-stat-live { + color: var(--muted); + font-size: 0.86rem; +} + +.ticket-stat-joined { + color: var(--ink); + font-size: 1rem; + font-weight: 700; +} + +.ticket-stat-joined svg { + width: 23px; + height: 23px; +} + +.ticket-stat-live svg { + width: 18px; + height: 18px; +} + +.ticket-progress { + grid-area: progress; + min-height: 176px; + padding: 18px 48px 22px; + border: 0; +} + +.ticket-progress::before { + top: 50px; + right: 15%; + left: 15%; + height: 3px; + background: linear-gradient( + to right, + var(--signal-blue) 0 55%, + transparent 55% 60%, + var(--signal-blue) 60% 64%, + transparent 64% 69%, + var(--signal-blue) 69% 73%, + transparent 73% 78%, + var(--signal-blue) 78% 82%, + transparent 82% + ); +} + +.ticket-step-marker { + width: 50px; + height: 50px; + border: 2px solid var(--signal-blue); + background: var(--paper); + color: var(--signal-blue); +} + +.ticket-step { + position: relative; + padding-top: 27px; +} + +.ticket-step::before { + position: absolute; + top: 0; + left: 50%; + z-index: 2; + width: 31px; + height: 31px; + display: grid; + place-items: center; + border: 2px solid var(--signal-blue); + border-radius: 50%; + background: var(--paper); + color: var(--signal-blue); + font-family: var(--body-font); + font-size: 0.94rem; + font-weight: 800; + line-height: 1; + transform: translateX(-50%); +} + +.ticket-step:nth-child(1)::before { + content: '1'; +} + +.ticket-step:nth-child(2)::before { + content: '2'; +} + +.ticket-step:nth-child(3)::before { + content: '3'; +} + +.ticket-step.is-active::before, +.ticket-step.is-complete::before { + background: var(--signal-blue); + color: #fff8ea; +} + +.ticket-step.is-active .ticket-step-marker, +.ticket-step.is-complete .ticket-step-marker { + background: var(--signal-blue); + color: #fff8ea; +} + +.ticket-step strong { + font-family: var(--display-font); + font-size: 1.55rem; +} + +.ticket-guidance { + grid-area: guidance; + min-height: 160px; + padding: 24px 38px; + border: 0; + border-top: var(--border); + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 28px; + background-color: var(--signal-blue); + background-image: + radial-gradient(circle at 38% 0%, rgb(24 111 211 / 50%), transparent 46%), + linear-gradient(155deg, #0752a8 0%, #063d8f 58%, #032f79 100%); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 16%); + color: #fff8ea; +} + +.ticket-guidance p { + max-width: 720px; + font-family: var(--display-font); + font-size: clamp(1.8rem, 2.35vw, 2.65rem); + line-height: 1.05; +} + +.ticket-guidance-message { + min-width: 0; + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + align-items: center; + gap: 28px; +} + +.ticket-guidance-message > svg { + width: 76px; + height: 76px; + padding-right: 24px; + border-right: 1px solid currentColor; +} + +.ticket-guidance-store { + width: 126px; + height: 92px; + opacity: 0.95; + stroke-width: 1.25; +} + +.ticket-join-area { + grid-area: join; + min-width: 0; + padding: 22px 30px; + border: 0; + border-top: var(--border); + border-left: var(--border); + background: var(--paper); +} + +.ticket-join-area h2 { + font-size: clamp(2rem, 3.2vw, 3.8rem); + line-height: 0.95; +} + +.ticket-join-copy, +.ticket-saved-state { + height: 100%; + gap: 22px; +} + +.ticket-join-area .button { + width: 100%; + background: var(--signal-blue); + color: #fff8ea; + border-color: var(--signal-blue); +} + +.ticket-unissued .ticket-board { + grid-template-areas: + 'hero stats' + 'join stats' + 'guidance stats'; + grid-template-rows: minmax(350px, 1fr) auto minmax(160px, auto); +} + +.ticket-unissued .ticket-progress { + display: none; +} + +.ticket-unissued .ticket-join-area { + padding: 22px 34px; + border-left: 0; +} + +.ticket-unissued .ticket-join-copy { + display: grid; + grid-template-columns: minmax(260px, 0.9fr) minmax(420px, 1.1fr); + align-items: center; + gap: 30px; +} + +.ticket-unissued .ticket-join-area form { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(190px, 0.75fr); + grid-template-areas: + 'label label' + 'input button' + 'hint hint'; + align-items: end; + gap: 8px 14px; +} + +.ticket-unissued .ticket-join-area form .field-label { + grid-area: label; +} + +.ticket-unissued .ticket-join-area form .text-input { + grid-area: input; +} + +.ticket-unissued .ticket-join-area form .field-hint { + grid-area: hint; + margin: 0; +} + +.ticket-unissued .ticket-join-area form .button { + grid-area: button; +} + +/* Staff */ + +.staff-experience .queue-surface-header { + border-bottom: var(--border); +} + +.staff-layout { + min-height: calc(100svh - 142px); + display: grid; + grid-template-columns: minmax(0, 1.68fr) minmax(390px, 1fr); + grid-template-rows: minmax(560px, 1fr) auto; +} + +.staff-active { + position: relative; + min-width: 0; + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + align-items: stretch; + margin: 34px; + padding: 0; + overflow: hidden; + border: 0; + border-radius: 28px; + background: var(--ticket-red); + color: var(--ticket-ink); +} + +.staff-ticket-stub { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; + padding: 24px; + border-right: 3px dashed #fff8ea; +} + +.staff-ticket-stub span:first-child { + font-family: var(--display-font); + font-size: 3rem; + line-height: 1; + text-transform: uppercase; + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.staff-ticket-stub span:last-child { + font-size: 2.2rem; +} + +.staff-ticket-body { + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: clamp(24px, 4vw, 60px); +} + +.staff-ticket-body .eyebrow { + display: none; +} + +.staff-active h2 { + margin: 0; + color: var(--ticket-ink); + font-size: clamp(2.4rem, 4vw, 5rem); + line-height: 1; +} + +.staff-active-number { + display: block; + max-width: 100%; + overflow: hidden; + color: var(--ticket-ink); + font-size: clamp(8rem, 20vw, 20rem); + line-height: 0.82; + letter-spacing: -0.04em; + white-space: nowrap; +} + +.staff-ticket-cut { + position: absolute; + top: 50%; + width: 64px; + height: 64px; + border-radius: 50%; + transform: translateY(-50%); +} + +.staff-ticket-cut-left { + left: -34px; +} + +.staff-ticket-cut-right { + right: -34px; +} + +.waiting-panel { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0; + border-left: var(--border); +} + +.waiting-heading { + min-height: 126px; + padding: 26px 36px; + border-bottom: var(--border); +} + +.waiting-heading h2 { + font-size: clamp(3rem, 5vw, 5.4rem); + line-height: 1; +} + +.waiting-list { + min-height: 0; + flex: 1; + display: grid; + grid-auto-rows: minmax(112px, 1fr); + padding: 0; + overflow-y: auto; +} + +.waiting-entry { + min-height: 0; + display: grid; + grid-template-columns: minmax(112px, auto) 1fr auto; + gap: 24px; + padding: 18px 30px; + border-bottom: 1px solid var(--ink); +} + +.waiting-entry .queue-number { + color: var(--accent); + font-size: clamp(2.1rem, 3.8vw, 4rem); + letter-spacing: -0.015em; + white-space: nowrap; +} + +.waiting-person { + display: flex; + flex-direction: column; + justify-content: center; +} + +.waiting-person strong { + font-size: 1.08rem; +} + +.waiting-person time { + color: var(--muted); + font-size: 0.78rem; +} + +.waiting-skip { + display: grid; + place-items: center; + gap: 3px; + text-decoration: none; +} + +.waiting-skip svg { + width: 30px; + height: 30px; + padding: 5px; + border: 1px solid currentColor; + border-radius: 50%; +} + +.empty-state { + margin: 36px; + padding: 40px; + border: 1px solid var(--ink); + background: transparent; +} + +.staff-command-bar { + grid-column: 1 / -1; + min-height: 148px; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 24px; + padding: 24px 36px; + border-top: var(--border); + background-color: var(--signal-blue); + background-image: + radial-gradient(circle at 38% 0%, rgb(24 111 211 / 50%), transparent 46%), + linear-gradient(155deg, #0752a8 0%, #063d8f 58%, #032f79 100%); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 16%); + color: #fff8ea; +} + +.staff-actions { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.staff-command-bar .button, +.staff-command-bar .text-button { + min-height: 60px; + border-color: #fff8ea; + color: #fff8ea; +} + +.staff-command-bar .button-accent { + background: var(--ticket-paper); + color: var(--ticket-ink); + border-color: var(--ticket-paper); +} + +.staff-command-bar .staff-primary-action { + min-width: 300px; + min-height: 70px; + font-size: 1.25rem; +} + +.staff-command-bar .staff-secondary-action { + border-color: rgb(255 248 234 / 55%); + color: rgb(255 248 234 / 78%); + min-height: 52px; +} + +.staff-command-bar .staff-close-action { + color: rgb(255 248 234 / 68%); + font-size: 0.92rem; +} + +.staff-command-bar .text-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 16px; +} + +.staff-command-bar svg { + width: 21px; + height: 21px; +} + +.staff-command-bar .notice { + min-width: 190px; + margin: 0; + padding: 18px 22px; + border: 1px solid currentColor; + background: transparent; + color: inherit; + font-family: var(--display-font); + font-size: 1.45rem; + text-align: center; +} + +.staff-experience > .setup-panel { + position: relative; + max-width: 860px; + margin: 70px auto; + padding: clamp(38px, 6vw, 72px); + border: 0; + border-radius: 28px; + background: var(--ticket-red); + color: var(--ticket-ink); +} + +.staff-experience > .setup-panel h2 { + font-family: var(--display-font); + font-size: clamp(3.5rem, 7vw, 7rem); + line-height: 0.9; +} + +/* Public display */ + +.display-page { + width: 100vw; + height: 100svh; + min-height: 0; + margin: 0; + padding: 0; + overflow: hidden; + border: var(--border); + background: var(--paper); +} + +.display-top { + height: 140px; + min-height: 140px; + padding: 0 34px; + border-bottom: var(--border); +} + +.display-identity { + gap: 28px; +} + +.display-identity > span { + align-self: stretch; + width: 2px; + height: auto; + background: var(--ink); +} + +.display-identity h1 { + font-family: var(--display-font); + font-weight: 400; + font-size: clamp(2.2rem, 4vw, 4.6rem); + line-height: 1; +} + +.display-board { + height: calc(100svh - 142px); + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 2.35fr) minmax(350px, 0.95fr); + grid-template-rows: minmax(0, 1fr) 160px 154px; + grid-template-areas: + 'active rail' + 'next next' + 'guide guide'; +} + +.display-active { + grid-area: active; + min-height: 0; + margin: 28px; + overflow: hidden; + border-radius: 28px; + background: var(--ticket-red); + color: var(--ticket-ink); +} + +.display-ticket-stub { + width: 198px; + min-width: 198px; + border-right: 3px dashed #fff8ea; + font-size: clamp(2rem, 3vw, 3.8rem); + writing-mode: horizontal-tb; + transform: none; +} + +.display-ticket-stub span:first-child { + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.display-active-number { + max-width: 100%; + overflow: hidden; + color: var(--ticket-ink); + font-size: clamp(9rem, 23vw, 23rem); + line-height: 0.76; + letter-spacing: -0.045em; + white-space: nowrap; +} + +.display-rail { + grid-area: rail; + border-left: var(--border); +} + +.display-rail > div { + padding: 30px 42px; + border-bottom: 1px solid var(--ink); +} + +.display-rail > div:first-child strong { + font-family: var(--display-font); + font-size: clamp(5rem, 9vw, 10rem); +} + +.display-up-next { + grid-area: next; + min-height: 0; + display: grid; + grid-template-columns: 160px 1fr; + padding: 0; + border-top: var(--border); + background: var(--paper); + color: var(--ink); +} + +.display-up-next .quiet-label { + display: grid; + place-items: center; + border-right: var(--border); + font-family: var(--display-font); + font-size: 2rem; + writing-mode: vertical-rl; + transform: rotate(180deg); + color: var(--ink); +} + +.display-up-next .next-numbers { + display: grid; + grid-template-columns: repeat(3, 1fr); +} + +.display-up-next .queue-number { + display: grid; + place-items: center; + border-right: 1px solid var(--ink); + font-size: clamp(4.5rem, 8vw, 9.5rem); +} + +.display-guidance { + grid-area: guide; + min-height: 0; + padding: 28px 50px; + background-color: var(--signal-blue); + background-image: + radial-gradient(circle at 38% 0%, rgb(24 111 211 / 50%), transparent 46%), + linear-gradient(155deg, #0752a8 0%, #063d8f 58%, #032f79 100%); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 16%); + color: #fff8ea; +} + +.display-guidance strong { + font-family: var(--display-font); + font-size: clamp(2.4rem, 2.8vw, 2.8rem); + line-height: 1; +} + +.display-guidance > svg { + width: 70px; + height: 70px; + padding-right: 24px; + border-right: 1px solid currentColor; + stroke-width: 1.4; +} + +.queue-preview, +.ticket-hero, +.staff-active, +.staff-experience > .setup-panel, +.display-active { + background-color: var(--ticket-red); + background-image: + radial-gradient(circle at 52% 3%, rgb(255 120 92 / 35%), transparent 38%), + linear-gradient(155deg, #f4321c 0%, #e72712 52%, #d91d0d 100%); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 25%), + inset 0 -38px 90px rgb(104 0 0 / 10%); +} + +.queue-venue-name, +.display-identity h1 { + font-family: var(--body-font); + font-weight: 700; + letter-spacing: -0.025em; +} + +.footer { + min-height: 110px; + padding: 30px; + border-right: var(--border); + border-bottom: var(--border); + border-left: var(--border); +} + +@media (max-width: 1100px) { + :root { + --page: calc(100vw - 24px); + } + + .site-header { + margin-top: 12px; + } + + .home-hero, + .demo-page { + grid-template-columns: 1fr; + } + + .home-hero .hero-copy { + border-right: 0; + border-bottom: var(--border); + } + + .demo-intro, + .demo-page > .setup-panel, + .demo-showcase, + .demo-note { + grid-column: 1; + grid-row: auto; + } + + .demo-intro { + order: 1; + } + + .demo-showcase { + order: 2; + } + + .demo-page > .setup-panel { + order: 3; + } + + .demo-note { + order: 4; + } + + .demo-page > .setup-panel { + min-height: 0; + margin: 0 36px 28px; + } + + .ticket-board { + grid-template-columns: minmax(0, 1.65fr) minmax(310px, 1fr); + } + + .ticket-unissued .ticket-join-area { + padding: 16px 24px; + } + + .ticket-unissued .ticket-join-copy { + grid-template-columns: minmax(190px, 0.9fr) minmax(250px, 1.1fr); + gap: 18px; + } + + .ticket-unissued .ticket-join-area form { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + 'label' + 'input' + 'hint' + 'button'; + } + + .staff-layout { + grid-template-columns: minmax(0, 1.25fr) minmax(360px, 1fr); + } + + .queue-surface-actions .status-chip { + display: none; + } +} + +@media (max-width: 820px) { + .site-header { + min-height: 92px; + padding: 0 18px; + } + + .brand { + padding-right: 22px; + font-size: 3.5rem; + } + + .desktop-nav { + display: none; + } + + .mobile-nav { + display: block; + } + + .mobile-menu { + left: auto; + right: 0; + width: min(220px, calc(100vw - 16px)); + min-width: 0; + border: var(--border); + border-radius: 0; + background: var(--paper); + } + + .home-hero .display-type, + .demo-intro h1, + .about-intro h1 { + font-size: clamp(4rem, 16vw, 7.2rem); + } + + .home-surface-list, + .surface-detail-grid, + .principles-grid, + .demo-grid, + .about-columns { + grid-template-columns: 1fr; + } + + .surface-detail-media { + aspect-ratio: 16 / 9; + } + + .surface-detail-copy { + min-height: 240px; + } + + .home-outcome-story, + .about-human-story { + grid-template-columns: 1fr; + } + + .home-outcome-media, + .about-human-media { + min-height: clamp(360px, 62vw, 540px); + border-right: 0; + border-bottom: var(--border); + } + + .home-outcome-copy, + .about-human-copy { + min-height: 390px; + } + + .home-surface-list li, + .demo-card, + .about-block { + border-right: 0; + border-bottom: var(--border); + } + + .surface-detail-copy { + min-height: 0; + padding: 24px 22px 28px; + } + + .surface-detail-heading { + margin-bottom: 18px; + } + + .home-outcome-media, + .about-human-media { + min-height: 300px; + } + + .home-outcome-copy, + .about-human-copy { + min-height: 0; + padding: 42px 24px 46px; + } + + .home-outcome-copy::before, + .home-outcome-copy::after { + display: none; + } + + .home-outcome-copy h2, + .about-human-copy h2 { + font-size: clamp(3.2rem, 15vw, 4.6rem); + } + + .home-signal-band { + grid-template-columns: auto 1fr; + padding: 28px; + } + + .home-store-icon { + display: none; + } + + .demo-page { + grid-template-columns: minmax(0, 1fr); + } + + .demo-note { + grid-template-columns: 1fr; + padding: 20px; + } + + .demo-proof { + border-right: 0; + border-bottom: 1px solid currentColor; + } + + .demo-proof:last-child { + border-bottom: 0; + } + + .demo-page > .setup-panel { + min-height: 0; + margin: 22px; + } + + .demo-showcase { + margin: 0 22px 22px; + } + + .about-intro::after { + display: none; + } + + .about-intro h1 { + max-width: none; + white-space: normal; + } + + .queue-page-shell { + padding: 8px; + } + + .ticket-experience, + .staff-experience { + min-height: calc(100svh - 16px); + } + + .queue-surface-header { + min-height: 108px; + flex-direction: row; + align-items: stretch; + padding: 0 18px; + } + + .queue-surface-identity { + gap: 14px; + } + + .queue-wordmark { + padding-right: 16px; + font-size: 3.3rem; + } + + .queue-surface-kicker { + display: none; + } + + .queue-venue-name { + max-width: 42vw; + font-size: 1.7rem; + } + + .queue-surface-actions .connection-state { + display: none; + } + + .queue-surface-actions { + align-self: stretch; + padding: 0; + border-left: 1px solid var(--ink); + } + + .ticket-board, + .ticket-unissued .ticket-board { + display: flex; + flex-direction: column; + } + + .ticket-issued .ticket-board { + height: auto; + min-height: calc(100svh - 108px); + } + + .ticket-unissued .ticket-join-copy { + display: flex; + } + + .ticket-unissued .ticket-join-area form { + grid-template-columns: 1fr; + grid-template-areas: + 'label' + 'input' + 'hint' + 'button'; + } + + .ticket-hero { + width: auto; + max-width: none; + min-height: 310px; + margin: 18px; + } + + .ticket-stub { + width: 92px; + min-width: 92px; + } + + .ticket-stub span:first-child { + font-size: 1.75rem; + } + + .ticket-number { + font-size: clamp(6.5rem, 27vw, 11rem); + } + + .ticket-stats { + order: 3; + border-top: var(--border); + border-left: 0; + } + + .ticket-progress { + order: 2; + } + + .ticket-join-area { + order: 4; + border-left: 0; + } + + .ticket-guidance { + order: 5; + } + + .staff-layout { + display: flex; + flex-direction: column; + } + + .staff-active { + min-height: 420px; + grid-template-columns: 96px 1fr; + margin: 18px; + } + + .staff-ticket-stub span:first-child { + font-size: 2rem; + } + + .staff-active-number { + font-size: clamp(5.2rem, 21vw, 8.5rem); + } + + .waiting-panel { + border-top: var(--border); + border-left: 0; + } + + .staff-command-bar { + grid-template-columns: 1fr; + } + + .staff-actions { + display: grid; + grid-template-columns: repeat(2, 1fr); + } + + .display-page { + width: calc(100% - 16px); + max-width: calc(100% - 16px); + height: auto; + min-height: calc(100svh - 16px); + margin: 8px; + overflow: visible; + } + + .display-top { + min-height: 110px; + padding: 0 18px; + } + + .display-statuses .connection-state { + display: none; + } + + .display-board { + height: auto; + display: flex; + flex-direction: column; + } + + .display-active { + min-height: 340px; + margin: 18px; + } + + .display-ticket-stub { + width: 100px; + min-width: 100px; + font-size: 2rem; + } + + .display-active-number { + font-size: clamp(7rem, 29vw, 13rem); + } + + .display-rail { + border-top: var(--border); + border-left: 0; + } + + .display-up-next { + grid-template-columns: 74px 1fr; + } +} + +@media (max-width: 520px) { + :root { + --page: calc(100vw - 16px); + } + + .site-header { + margin-top: 8px; + } + + .home-hero .hero-copy, + .demo-intro, + .about-intro { + padding: 36px 22px; + } + + .home-hero .display-type, + .demo-intro h1, + .about-intro h1 { + font-size: clamp(3.8rem, 20vw, 5.5rem); + } + + .about-manifesto { + font-size: clamp(2.5rem, 13vw, 4rem); + overflow-wrap: anywhere; + } + + .about-block { + grid-template-columns: 58px 46px minmax(0, 1fr); + gap: 14px; + padding: 24px 18px; + } + + .about-block h2 { + overflow-wrap: anywhere; + } + + .about-index { + font-size: 3rem; + } + + .about-icon { + width: 42px; + height: 42px; + } + + .queue-preview { + min-height: 360px; + height: 360px; + margin: 16px; + } + + .home-surface-list .surface-row { + min-height: 92px; + } + + .home-signal-band { + grid-template-columns: 40px minmax(0, 1fr); + gap: 14px; + padding: 24px 20px; + } + + .home-signal-band > svg:first-child { + width: 40px; + height: 48px; + padding-right: 10px; + } + + .home-signal-band > div { + min-width: 0; + } + + .home-signal-band h2, + .home-signal-band p { + overflow-wrap: anywhere; + } + + .demo-grid { + margin: 0; + } + + .demo-showcase { + margin: 0 16px 16px; + } + + .demo-showcase-media { + min-height: 220px; + } + + .demo-page > .setup-panel { + grid-template-columns: 58px minmax(0, 1fr); + margin: 0 16px 16px; + border-radius: 20px; + } + + .setup-ticket-stub { + font-size: 1.05rem; + } + + .setup-panel-content { + padding: 30px 22px; + } + + .demo-page .setup-form { + grid-template-columns: 1fr; + grid-template-areas: + 'name-label' + 'name-input' + 'prefix-label' + 'prefix-input' + 'submit'; + } + + .queue-surface-actions .status-chip, + .queue-surface-actions .connection-state { + display: none; + } + + .queue-venue-name { + max-width: none; + display: -webkit-box; + overflow: hidden; + font-size: clamp(1.05rem, 5vw, 1.3rem); + line-height: 1; + text-overflow: clip; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .ticket-hero { + min-height: 260px; + } + + .ticket-number-field { + padding: 10px; + } + + .ticket-number { + font-size: clamp(5rem, 22vw, 6.5rem); + } + + .ticket-stub { + width: 74px; + min-width: 74px; + } + + .ticket-stub span:first-child { + font-size: 1.35rem; + } + + .ticket-progress { + padding: 22px 20px; + } + + .ticket-step small { + display: none; + } + + .ticket-guidance, + .ticket-join-area { + padding: 24px; + } + + .ticket-guidance { + grid-template-columns: 1fr; + } + + .ticket-guidance-message { + grid-template-columns: 48px minmax(0, 1fr); + gap: 16px; + } + + .ticket-guidance-message > svg { + width: 48px; + height: 52px; + padding-right: 14px; + } + + .ticket-guidance-store { + display: none; + } + + .ticket-guidance p { + font-size: clamp(1.6rem, 8vw, 2rem); + } + + .staff-active { + min-height: 330px; + grid-template-columns: 76px 1fr; + } + + .staff-ticket-body { + padding: 20px; + } + + .staff-active h2 { + font-size: 2rem; + } + + .waiting-entry { + grid-template-columns: 94px 1fr auto; + gap: 10px; + padding: 14px 18px; + } + + .waiting-entry .queue-number { + font-size: 2rem; + } + + .waiting-person time { + display: none; + } + + .staff-command-bar { + padding: 20px; + } + + .staff-actions { + grid-template-columns: 1fr; + } + + .display-identity h1 { + font-size: 1.6rem; + } + + .display-identity p, + .display-statuses .status-chip { + display: none; + } + + .display-up-next .queue-number { + font-size: 2.5rem; + } + + .display-guidance { + padding: 24px; + } +} + +/* Keep the full live-information rail visible on laptop and tablet layouts. */ +@media (min-width: 681px) and (max-width: 980px) { + .queue-page-shell { + padding: 0; + } + + .ticket-experience { + min-height: 100svh; + } + + .queue-surface-header { + min-height: 108px; + } + + .ticket-board, + .ticket-unissued .ticket-board { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(280px, 0.9fr); + grid-template-areas: + 'hero stats' + 'progress stats' + 'guidance join'; + grid-template-rows: minmax(280px, 1fr) 128px 124px; + } + + .ticket-issued .ticket-board { + height: calc(100svh - 108px); + min-height: 0; + grid-template-rows: minmax(280px, 1fr) 128px 124px; + } + + .ticket-unissued .ticket-board { + grid-template-areas: + 'hero stats' + 'join stats' + 'guidance stats'; + grid-template-rows: minmax(280px, 1fr) auto 124px; + } + + .ticket-hero { + width: auto; + max-width: calc(100% - 28px); + min-height: 0; + margin: 14px; + } + + .ticket-stub { + width: 78px; + min-width: 78px; + } + + .ticket-stub span:first-child { + font-size: 1.35rem; + } + + .ticket-number { + font-size: clamp(6rem, 12vw, 10rem); + } + + .ticket-stats { + order: initial; + grid-template-columns: 1fr; + grid-template-rows: 1.2fr 1.2fr 0.8fr 0.65fr 0.65fr; + border-top: 0; + border-left: var(--border); + } + + .ticket-stat, + .ticket-stat:nth-child(even) { + min-height: 0; + padding: 12px 18px; + border-right: 0; + } + + .ticket-stat-position strong { + font-size: clamp(3.2rem, 7vw, 4.8rem); + } + + .ticket-stat-serving strong { + font-size: clamp(3rem, 6vw, 4.4rem); + } + + .ticket-progress { + order: initial; + min-height: 128px; + padding: 14px 28px 16px; + } + + .ticket-progress::before { + top: 42px; + } + + .ticket-step { + padding-top: 22px; + } + + .ticket-step::before { + width: 27px; + height: 27px; + } + + .ticket-step-marker { + width: 40px; + height: 40px; + } + + .ticket-step strong { + font-size: 1.25rem; + } + + .ticket-guidance { + order: initial; + min-height: 124px; + padding: 18px 26px; + } + + .ticket-guidance-message { + grid-template-columns: 56px minmax(0, 1fr); + gap: 18px; + } + + .ticket-guidance-message > svg { + width: 56px; + height: 60px; + padding-right: 16px; + } + + .ticket-guidance p { + font-size: clamp(1.5rem, 3.2vw, 2rem); + } + + .ticket-guidance-store { + display: none; + } + + .ticket-join-area { + order: initial; + padding: 14px 18px; + border-left: var(--border); + } + + .ticket-issued .ticket-join-area h2 { + font-size: 1.65rem; + } + + .ticket-issued .ticket-saved-state p { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .button { + transition: none; + } + + .surface-detail-media img, + .home-outcome-media img, + .about-human-media img { + transition: none; + } +} + +/* Soft geometry: preserve the ticket language without sharp application boxes. */ +.site-header, +.home-hero, +.home-outcome-story, +.about-intro, +.about-human-story, +.demo-page, +.demo-note, +.ticket-experience, +.staff-experience, +.display-page { + border-radius: var(--radius-lg); + overflow: hidden; +} + +.surface-detail-grid, +.principles-grid { + gap: 18px; + padding: 18px; + border: 0; +} + +.surface-detail-grid article, +.principle, +.about-block { + border: var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.about-columns { + gap: 16px; + padding: 16px; +} + +.about-block, +.about-block:nth-child(even), +.demo-card, +.demo-card:last-child { + border: 1px solid var(--ink); +} + +.queue-preview, +.staff-active, +.display-active { + border-radius: var(--radius-lg); +} + +.notice { + border-radius: var(--radius-sm); +} + +.status-chip, +.connection-state { + border-radius: 999px; +} diff --git a/src/components/connection-indicator.tsx b/src/components/connection-indicator.tsx index 5fd82f7..a7a13ea 100644 --- a/src/components/connection-indicator.tsx +++ b/src/components/connection-indicator.tsx @@ -8,12 +8,17 @@ const labels: Record = { error: 'Connection issue', }; -export function ConnectionIndicator({ state }: { state: ConnectionState }) { +export function ConnectionIndicator({ + state, + announce = true, +}: { + state: ConnectionState; + announce?: boolean; +}) { return (
- +

+ {snapshot.queue.status === 'PAUSED' + ? 'Staff will reopen the queue shortly.' + : 'Please check back during service hours.'} +

+ + ); + + return ( + <> + +

+ {message} +

+ ); } diff --git a/src/features/queue/customer-prototype.tsx b/src/features/queue/customer-prototype.tsx index 0af5f5e..ff8196a 100644 --- a/src/features/queue/customer-prototype.tsx +++ b/src/features/queue/customer-prototype.tsx @@ -1,8 +1,7 @@ 'use client'; import { FormEvent, useState, useSyncExternalStore } from 'react'; -import { AnimatedQueueNumber } from '@/components/animated-queue-number'; -import { QueueStatusLabel } from '@/components/queue-status'; +import { CustomerTicket } from './customer-ticket'; import { formatQueueNumber } from './format'; import { createInitialQueueSnapshot } from './mock-data'; import { activeEntry, applyQueueCommand, waitingEntries } from './transitions'; @@ -71,100 +70,92 @@ export function CustomerPrototype({ ? waiting.findIndex((entry) => entry.id === joinedEntry.id) + 1 : 0; - return ( -
-
-

Join the queue

-

Keep your place. Keep your day.

-

- Add a first name if you like. Your queue number is all we need. -

- {snapshot.queue.status === 'OPEN' ? ( -
- - setDisplayName(event.target.value)} - disabled={Boolean(joinedEntry)} - /> -

- Only your first name is used on the staff view. -

- -
- ) : ( -

- {snapshot.queue.status === 'PAUSED' - ? 'Check-in is paused. Staff will reopen the queue shortly.' - : 'This queue is closed. Please check back during service hours.'} + const joinContent = joinedEntry ? ( +

+
+

Private by design

+

Your place is saved.

+
+

+ This ticket belongs to this browser session. No account, email, or phone + number required. +

+ +
+ ) : snapshot.queue.status === 'OPEN' ? ( +
+
+

Step into line

+

Claim your ticket.

+

Add a first name if you like. Your number is all we need.

+
+
+
+ + setDisplayName(event.target.value)} + disabled={Boolean(joinedEntry)} + /> +

+ Only your first name is used on the staff view.

- )} -

- {message} -

-
- -
-
- -

- Your number -

- {joinedEntry ? ( - - ) : ( - - — - - )} -

- {joinedEntry - ? 'We’ll keep your position in this browser session.' - : 'Join to receive your queue number.'} -

-
-
- - Now serving -
- {active - ? formatQueueNumber(active.number, snapshot.queue.prefix) - : 'No one yet'} -
- - Your position -
- {joinedEntry ? `${position} of ${waiting.length}` : '—'} -
-
-
+ {joinedEntry ? 'You’re in the queue' : 'Join the queue'} + + +
+ ) : ( +
+
+

Check-in unavailable

+

+ {snapshot.queue.status === 'PAUSED' + ? 'The line is taking a breather.' + : 'Service has ended for now.'} +

+
+

+ {snapshot.queue.status === 'PAUSED' + ? 'Check-in is paused. Staff will reopen the queue shortly.' + : 'This queue is closed. Please check back during service hours.'} +

+
+ ); + + return ( + <> + +

+ {message} +

+ ); } diff --git a/src/features/queue/customer-ticket.tsx b/src/features/queue/customer-ticket.tsx new file mode 100644 index 0000000..b1e90b5 --- /dev/null +++ b/src/features/queue/customer-ticket.tsx @@ -0,0 +1,188 @@ +import { + Bell, + Check, + Clock3, + Info, + Megaphone, + Radio, + Store, + Users, +} from 'lucide-react'; +import type { ReactNode } from 'react'; +import { AnimatedQueueNumber } from '@/components/animated-queue-number'; +import { QueueSurfaceHeader } from '@/components/queue-surface-header'; +import type { ConnectionState, QueueEntry, QueueStatus } from './types'; + +const joinedTime = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', +}); + +export function CustomerTicket({ + queueName, + queueStatus, + prefix, + ownEntry, + activeLabel, + position, + waitingCount, + connection, + announceConnection = true, + joinContent, +}: { + queueName: string; + queueStatus: QueueStatus; + prefix: string; + ownEntry: QueueEntry | undefined; + activeLabel: string; + position: number; + waitingCount: number; + connection: ConnectionState; + announceConnection?: boolean; + joinContent: ReactNode; +}) { + const peopleAhead = ownEntry + ? ownEntry.status === 'SERVING' + ? 0 + : Math.max(position - 1, 0) + : undefined; + const called = + ownEntry?.status === 'SERVING' || ownEntry?.status === 'COMPLETED'; + const queueMotion = + queueStatus === 'OPEN' + ? 'Queue is moving' + : queueStatus === 'PAUSED' + ? 'Queue is paused' + : 'Queue is closed'; + const liveUpdateLabel: Record = { + connected: 'Live updates connected', + connecting: 'Live updates starting', + reconnecting: 'Live updates resuming', + offline: 'Live updates interrupted', + error: 'Live updates unavailable', + }; + const guidance = called + ? 'It’s your turn.' + : ownEntry + ? 'Stay nearby — this page updates automatically.' + : 'Your name is optional and never shown to other guests.'; + + return ( +
+ + +
+
+
+ + {ownEntry ? "You're in line" : 'Your ticket'} + + +
+
+ +
+
+ + + +
+
+ + + Joined + {ownEntry ? 'Place saved' : 'Not yet'} +
+
+ + + Waiting + + {ownEntry && !called ? 'You’re in line' : 'Next step'} + +
+
+ + + Called + {called ? 'It’s your turn' : 'We’ll show it here'} +
+
+ +
+
+
+
+ +
+ {joinContent} +
+
+

+ {waitingCount} customers are currently waiting. +

+
+ ); +} diff --git a/src/features/queue/public-display-live.tsx b/src/features/queue/public-display-live.tsx index f2c8eeb..80d6f40 100644 --- a/src/features/queue/public-display-live.tsx +++ b/src/features/queue/public-display-live.tsx @@ -1,8 +1,10 @@ 'use client'; +import { BellRing } from 'lucide-react'; import { AnimatedQueueNumber } from '@/components/animated-queue-number'; import { ConnectionIndicator } from '@/components/connection-indicator'; import { InvalidQueue } from '@/components/invalid-queue'; +import { QueueStatusLabel } from '@/components/queue-status'; import { activeEntry, waitingEntries } from './transitions'; import { useLiveQueue } from './use-live-queue'; @@ -18,49 +20,90 @@ export function PublicDisplayLive({ slug }: { slug: string }) {
); const active = activeEntry(snapshot.entries); - const upcoming = waitingEntries(snapshot.entries).slice(0, 3); + const waiting = waitingEntries(snapshot.entries); + const upcoming = waiting.slice(0, 3); return (
-
-

Welcome

-

{snapshot.queue.name}

+
+ NEXT +
+
+ +
-
-
-
-

- Now serving -

+
+
+
+ Now serving + +
+
-
-
-

- Up next -

-
- {upcoming.length ? ( - upcoming.map((entry) => ( - - {entry.numberLabel} - - )) - ) : ( - Queue is clear - )} -
-
+
+ +
+

+ Up next +

+
+ {upcoming.length ? ( + upcoming.map((entry) => ( + + {entry.numberLabel} + + )) + ) : ( + Queue is clear + )} +
+
+
+
+
); } diff --git a/src/features/queue/public-display.tsx b/src/features/queue/public-display.tsx index aad86e1..4866038 100644 --- a/src/features/queue/public-display.tsx +++ b/src/features/queue/public-display.tsx @@ -1,5 +1,7 @@ +import { BellRing } from 'lucide-react'; import { AnimatedQueueNumber } from '@/components/animated-queue-number'; import { ConnectionIndicator } from '@/components/connection-indicator'; +import { QueueStatusLabel } from '@/components/queue-status'; import { formatQueueNumber } from './format'; import { createInitialQueueSnapshot } from './mock-data'; import { activeEntry, waitingEntries } from './transitions'; @@ -12,50 +14,85 @@ export function PublicDisplay({ }) { const snapshot = initialSnapshot ?? createInitialQueueSnapshot(); const active = activeEntry(snapshot.entries); - const upNext = waitingEntries(snapshot.entries).slice(0, 3); + const waiting = waitingEntries(snapshot.entries); + const upNext = waiting.slice(0, 3); return (
-
-

Welcome

-

{snapshot.queue.name}

+
+ NEXT +
+
+ +
-
-
-
-

- Now serving -

+
+
+
+ Now serving + +
+
-
-
-

- Up next -

-
- {upNext.length ? ( - upNext.map((entry) => ( - - {formatQueueNumber(entry.number, snapshot.queue.prefix)} - - )) - ) : ( - Queue is clear - )} -
-
+ + +
+

+ Up next +

+
+ {upNext.length ? ( + upNext.map((entry) => ( + + {formatQueueNumber(entry.number, snapshot.queue.prefix)} + + )) + ) : ( + Queue is clear + )} +
+
+
+
+
); } diff --git a/src/features/queue/staff-live.tsx b/src/features/queue/staff-live.tsx index 0bfbc7d..f54051c 100644 --- a/src/features/queue/staff-live.tsx +++ b/src/features/queue/staff-live.tsx @@ -1,16 +1,28 @@ 'use client'; import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; +import { + CheckCircle2, + Megaphone, + Pause, + Play, + SkipForward, + XCircle, +} from 'lucide-react'; import { FormEvent, useRef, useState } from 'react'; import { AnimatedQueueNumber } from '@/components/animated-queue-number'; -import { ConnectionIndicator } from '@/components/connection-indicator'; import { InvalidQueue } from '@/components/invalid-queue'; -import { QueueStatusLabel } from '@/components/queue-status'; +import { QueueSurfaceHeader } from '@/components/queue-surface-header'; import { QueueAdapterError } from '@/lib/realtime/errors'; import { activeEntry, waitingEntries } from './transitions'; import { useLiveQueue } from './use-live-queue'; import type { QueueSnapshot } from './types'; +const joinedTime = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', +}); + export function StaffLive({ slug }: { slug: string }) { const { adapter, snapshot, connection, error, commit, isNotFound } = useLiveQueue(slug); @@ -31,9 +43,14 @@ export function StaffLive({ slug }: { slug: string }) { async function claim(event: FormEvent) { event.preventDefault(); if (!adapter) return; + const normalizedAccessCode = accessCode.trim().toUpperCase(); + if (!normalizedAccessCode) { + setMessage('Enter the access code for this queue.'); + return; + } setPending('claim'); try { - const next = await adapter.claimStaffAccess(slug, accessCode); + const next = await adapter.claimStaffAccess(slug, normalizedAccessCode); setAccessCode(''); commit(next); setMessage('Staff access confirmed.'); @@ -50,54 +67,65 @@ export function StaffLive({ slug }: { slug: string }) { if (snapshot.role !== 'staff') { return ( -
-

Staff access

-

Enter the queue access code.

-

- The code grants control of this queue. It is never placed in the URL - or saved by this interface. -

-
- - setAccessCode(event.target.value)} - required - disabled={pending === 'claim'} - /> - +
+

- {pending === 'claim' ? 'Checking…' : 'Open staff board'} - - -

- {message} -

-
+ {message} +

+ + ); } const active = activeEntry(snapshot.entries); const waiting = waitingEntries(snapshot.entries); + const currentSnapshot = snapshot; async function command( label: string, @@ -120,196 +148,249 @@ export function StaffLive({ slug }: { slug: string }) { } } + async function completeServing() { + if (!adapter || !active) return; + const callNext = + currentSnapshot.queue.status === 'OPEN' && waiting.length > 0; + setPending('complete'); + try { + const completed = await adapter.completeCurrent(currentSnapshot.queue.id); + commit(completed); + + if (callNext) { + const next = await adapter.callNext(currentSnapshot.queue.id); + const nextActive = activeEntry(next.entries); + commit(next); + setMessage( + `${active.numberLabel} completed. ${nextActive?.numberLabel ?? 'Next customer'} called next.`, + ); + } else { + setMessage(`${active.numberLabel} completed.`); + } + requestAnimationFrame(() => messageRef.current?.focus()); + } catch (nextError) { + setMessage( + nextError instanceof QueueAdapterError + ? nextError.message + : 'The queue could not be updated.', + ); + } finally { + setPending(''); + } + } + return ( -
-
-
- - -
-
-

Now serving

-

- {active?.displayName ?? - (active ? 'Current customer' : 'No one yet')} -

-
- -
- {active ? ( - <> +
+ +
+
+ +
+
+

Now serving

+

+ {active?.displayName ?? + (active ? 'Current customer' : 'No one yet')} +

+
+ +
+ + +
+ +
+
+

Waiting

+ + {waiting.length} {waiting.length === 1 ? 'person' : 'people'} + +
+ {waiting.length ? ( +
    + + {waiting.map((entry) => ( + + {entry.numberLabel} + + {entry.displayName ?? 'Guest'} + + + + + ))} + +
+ ) : ( +

+ No customers waiting. New arrivals will appear here automatically. +

+ )} +
+ +
+
+ {active ? ( + <> + + + + ) : ( + + )} + {snapshot.queue.status === 'OPEN' && ( + )} + {snapshot.queue.status === 'PAUSED' && ( - - ) : ( - - )} - {snapshot.queue.status === 'OPEN' && ( - - )} - {snapshot.queue.status === 'PAUSED' && ( - - )} - {snapshot.queue.status !== 'CLOSED' && ( - - )} -
-

- {pending ? 'Updating the queue…' : message} -

-
-
-
-

Waiting

- - {waiting.length} {waiting.length === 1 ? 'person' : 'people'} - -
- {waiting.length ? ( -
    - - {waiting.map((entry) => ( - - {entry.numberLabel} - {entry.displayName ?? 'Guest'} - - - ))} - -
- ) : ( -

- No customers waiting. New arrivals will appear here automatically. + )} + {snapshot.queue.status !== 'CLOSED' && ( + + )} +

+

+ {pending ? 'Updating the queue…' : message}

- )} - + + ); } diff --git a/src/features/queue/staff-prototype.test.tsx b/src/features/queue/staff-prototype.test.tsx index 28beaba..d6a8e82 100644 --- a/src/features/queue/staff-prototype.test.tsx +++ b/src/features/queue/staff-prototype.test.tsx @@ -7,9 +7,9 @@ describe('StaffPrototype', () => { it('completes the active customer and calls the next waiting number', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole('button', { name: 'Complete' })); - expect(screen.getByRole('button', { name: 'Call next' })).toBeEnabled(); - await user.click(screen.getByRole('button', { name: 'Call next' })); + await user.click( + screen.getByRole('button', { name: 'Complete & call next' }), + ); expect(screen.getByLabelText('Queue number A-025')).toBeInTheDocument(); }); diff --git a/src/features/queue/staff-prototype.tsx b/src/features/queue/staff-prototype.tsx index 8115ed1..6b9440b 100644 --- a/src/features/queue/staff-prototype.tsx +++ b/src/features/queue/staff-prototype.tsx @@ -3,8 +3,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import { useState } from 'react'; import { AnimatedQueueNumber } from '@/components/animated-queue-number'; -import { ConnectionIndicator } from '@/components/connection-indicator'; -import { QueueStatusLabel } from '@/components/queue-status'; +import { QueueSurfaceHeader } from '@/components/queue-surface-header'; import { formatQueueNumber } from './format'; import { createInitialQueueSnapshot } from './mock-data'; import { @@ -41,6 +40,37 @@ export function StaffPrototype({ } } + function completeServing() { + if (!active) return; + const completedLabel = formatQueueNumber( + active.number, + snapshot.queue.prefix, + ); + const callNext = snapshot.queue.status === 'OPEN' && waiting.length > 0; + + try { + setSnapshot((current) => { + const completed = applyQueueCommand(current, { + type: 'COMPLETE_ACTIVE', + }); + return callNext + ? applyQueueCommand(completed, { type: 'CALL_NEXT' }) + : completed; + }); + setMessage( + callNext + ? `${completedLabel} completed. Next customer called.` + : `${completedLabel} completed.`, + ); + } catch (error) { + setMessage( + error instanceof QueueTransitionError + ? error.message + : 'The queue could not be updated.', + ); + } + } + const toggleStatus = () => { const opening = snapshot.queue.status !== 'OPEN'; run( @@ -50,130 +80,124 @@ export function StaffPrototype({ }; return ( -
-
-
- - -
-
-

Now serving

-

- {active?.displayName ?? - (active ? 'Current customer' : 'No one yet')} -

-
- -
- {active ? ( - <> - +
+ +
+
+
+

Now serving

+

+ {active?.displayName ?? + (active ? 'Current customer' : 'No one yet')} +

+
+ +
+ {active ? ( + <> + + + + ) : ( - - ) : ( + )} - )} - -
-

- {message} -

-
+
+

+ {message} +

+
-
-
-

Waiting

- - {waiting.length} {waiting.length === 1 ? 'person' : 'people'} - -
- {waiting.length ? ( -
    - - {waiting.map((entry) => ( - - - {formatQueueNumber(entry.number, snapshot.queue.prefix)} - - {entry.displayName ?? 'Guest'} - - - ))} - -
- ) : ( -

- No customers waiting. New arrivals will appear here. -

- )} -
+ + {formatQueueNumber(entry.number, snapshot.queue.prefix)} + + {entry.displayName ?? 'Guest'} + + + ))} + + + ) : ( +

+ No customers waiting. New arrivals will appear here. +

+ )} + +
); } diff --git a/src/lib/realtime/realtime-controller.test.ts b/src/lib/realtime/realtime-controller.test.ts index 2eb2afa..c1aea26 100644 --- a/src/lib/realtime/realtime-controller.test.ts +++ b/src/lib/realtime/realtime-controller.test.ts @@ -62,10 +62,8 @@ function fakeClient(revisions: number[]) { })); const client = { auth: { - getSession: vi.fn(async () => ({ - data: { - session: { user: { id: '51000000-0000-4000-8000-000000000001' } }, - }, + getUser: vi.fn(async () => ({ + data: { user: { id: '51000000-0000-4000-8000-000000000001' } }, error: null, })), signInAnonymously: vi.fn(), diff --git a/src/lib/supabase/session.test.ts b/src/lib/supabase/session.test.ts index eb4a299..7c524e1 100644 --- a/src/lib/supabase/session.test.ts +++ b/src/lib/supabase/session.test.ts @@ -13,9 +13,9 @@ function client(existing = false) { const user = { id: '60000000-0000-4000-8000-000000000001' }; const value = { auth: { - getSession: vi.fn(async () => ({ - data: { session: existing ? { user } : null }, - error: null, + getUser: vi.fn(async () => ({ + data: { user: existing ? user : null }, + error: existing ? null : { message: 'session missing' }, })), signInAnonymously: vi.fn(async () => ({ data: { user }, error: null })), }, @@ -42,6 +42,21 @@ describe('ensureAnonymousSession', () => { expect(fake.value.auth.signInAnonymously).toHaveBeenCalledTimes(1); }); + it('revalidates a resolved session before the next operation', async () => { + const fake = client(true); + await ensureAnonymousSession(fake.typed); + fake.value.auth.getUser.mockResolvedValueOnce({ + data: { user: null }, + error: { message: 'session missing' }, + }); + + await expect(ensureAnonymousSession(fake.typed)).resolves.toMatchObject({ + id: expect.any(String), + }); + expect(fake.value.auth.getUser).toHaveBeenCalledTimes(2); + expect(fake.value.auth.signInAnonymously).toHaveBeenCalledTimes(1); + }); + it('surfaces a typed recoverable initialization error', async () => { const fake = client(); fake.value.auth.signInAnonymously.mockResolvedValueOnce({ diff --git a/src/lib/supabase/session.ts b/src/lib/supabase/session.ts index 417f2a0..737f2fa 100644 --- a/src/lib/supabase/session.ts +++ b/src/lib/supabase/session.ts @@ -18,22 +18,25 @@ export function ensureAnonymousSession( ): Promise { if (pendingSession) return pendingSession; - pendingSession = (async () => { - const { data: sessionData, error: sessionError } = - await client.auth.getSession(); - if (sessionError) throw new AnonymousSessionError(); - if (sessionData.session?.user) return sessionData.session.user; + const initialization = (async () => { + const { data: userData, error: userError } = await client.auth.getUser(); + if (!userError && userData.user) return userData.user; const { data, error } = await client.auth.signInAnonymously(); if (error || !data.user) throw new AnonymousSessionError(); return data.user; })().catch((error: unknown) => { - pendingSession = undefined; if (error instanceof AnonymousSessionError) throw error; throw new AnonymousSessionError(); }); - return pendingSession; + pendingSession = initialization; + void initialization.then(clearPendingSession, clearPendingSession); + return initialization; +} + +function clearPendingSession() { + pendingSession = undefined; } export function resetAnonymousSessionForTests() { diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 6860250..1532d0f 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -37,10 +37,10 @@ test('landing and demo routes communicate the persistent product', async ({ await page.getByRole('link', { name: /open the demo/i }).click(); await expect(page).toHaveURL(/\/demo$/); await expect( - page.getByRole('heading', { name: /every side/i }), + page.getByRole('heading', { name: /three points of view/i }), ).toBeVisible(); await expect( - page.getByRole('heading', { name: /create a queue/i }), + page.getByRole('heading', { name: /issue your queue/i }), ).toBeVisible(); }); @@ -77,7 +77,7 @@ test('theme switching applies the dark theme', async ({ page }) => { test('reduced motion keeps live content visible', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/q/north-star-cafe/display'); - await expect(page.getByText('Queue is clear')).toBeVisible(); + await expect(page.getByText('Queue is moving')).toBeVisible(); }); for (const width of [320, 375]) { diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 67d2d4a..924b19e 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -9,9 +9,9 @@ export async function createTestQueue( const context = await browser.newContext(); const page = await context.newPage(); await page.goto('/demo'); - await page.getByLabel('Queue name').fill(name); - await page.getByLabel('Number prefix').fill(prefix); - await page.getByRole('button', { name: 'Create persistent queue' }).click(); + await page.getByLabel('Venue or queue name').fill(name); + await page.getByLabel('Ticket prefix').fill(prefix); + await page.getByRole('button', { name: 'Create my queue' }).click(); const codeField = page.getByLabel('One-time staff access code'); await expect(codeField).toBeVisible(); const code = await codeField.inputValue(); @@ -24,7 +24,9 @@ export async function claimStaff(browser: Browser, slug: string, code: string) { const context = await browser.newContext(); const page = await context.newPage(); await page.goto(`/q/${slug}/staff`); - await page.getByLabel('Private access code').fill(code); + await page + .getByLabel('Private access code') + .fill(` ${code.toLowerCase()} `); await page.getByRole('button', { name: 'Open staff board' }).click(); await expect(page.getByRole('heading', { name: 'Waiting' })).toBeVisible(); return { context, page }; diff --git a/tests/e2e/realtime.spec.ts b/tests/e2e/realtime.spec.ts index ae2966c..efc1abc 100644 --- a/tests/e2e/realtime.spec.ts +++ b/tests/e2e/realtime.spec.ts @@ -24,24 +24,19 @@ test('customer, staff, and display converge without refresh and resync after off await expect(created.page.getByText('River')).toBeVisible(); await expect(created.page.getByText('Sky')).toBeVisible(); await expect(display.getByText('R-001')).toBeVisible(); - await expect(second.page.getByText('2 of 2')).toBeVisible(); + await expect(second.page.getByText('Your position: 2 of 2')).toBeVisible(); await created.page.getByRole('button', { name: 'Call next' }).click(); await expect(first.page.getByText('It’s your turn.')).toBeVisible(); await expect(display.getByLabel('Queue number R-001')).toBeVisible(); - await expect(second.page.getByText('1 of 1')).toBeVisible(); - - await created.page.getByRole('button', { name: 'Complete' }).click(); - await expect( - display.getByText('No one is currently being served'), - ).toBeAttached(); - await expect( - created.page.getByRole('button', { name: 'Call next' }), - ).toBeEnabled(); + await expect(second.page.getByText('Your position: 1 of 1')).toBeVisible(); await second.context.setOffline(true); await expect(second.page.getByText('Offline')).toBeVisible(); - await created.page.getByRole('button', { name: 'Call next' }).click(); + await created.page + .getByRole('button', { name: 'Complete & call next' }) + .click(); + await expect(display.getByLabel('Queue number R-002')).toBeVisible(); await second.context.setOffline(false); await expect(second.page.getByText('It’s your turn.')).toBeVisible(); await expect(