diff --git a/.env.example b/.env.example index 957e7b5..d57b63e 100644 --- a/.env.example +++ b/.env.example @@ -15,5 +15,6 @@ NEXT_PUBLIC_SEATLAYER_SEASON_KEY= # Server only. Never prefixed with NEXT_PUBLIC_, so it stays out of the browser # bundle. Enables the hold inspection and booking path in app/api/hold/route.ts -# and the real event catalogue in app/api/events/route.ts. +# and the real event names in app/api/events/route.ts, which lists only the +# event keys above. SEATLAYER_SECRET_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc731a6..9733e82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,12 @@ on: jobs: build: runs-on: ubuntu-latest + env: + # Repository secrets for a test event. Forks do not have them, so there + # the job only builds and the route checks are skipped. + NEXT_PUBLIC_SEATLAYER_EVENT_KEY: ${{ secrets.DEMO_EVENT_KEY }} + NEXT_PUBLIC_SEATLAYER_PUBLIC_KEY: ${{ secrets.DEMO_PUBLIC_KEY }} + NEXT_PUBLIC_SEATLAYER_SEASON_KEY: ${{ secrets.DEMO_SEASON_KEY }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -14,3 +20,16 @@ jobs: node-version: 20 - run: npm ci - run: npm run build + + - name: Install the route check browser + if: env.NEXT_PUBLIC_SEATLAYER_EVENT_KEY != '' && env.NEXT_PUBLIC_SEATLAYER_PUBLIC_KEY != '' + run: | + npm install --no-save playwright@1.63.0 + npx playwright install --with-deps chromium + + - name: Load every route + if: env.NEXT_PUBLIC_SEATLAYER_EVENT_KEY != '' && env.NEXT_PUBLIC_SEATLAYER_PUBLIC_KEY != '' + run: | + npm start & + for i in $(seq 1 30); do curl -sf http://localhost:3000 > /dev/null && break; sleep 2; done + node scripts/check-routes.mjs diff --git a/.gitignore b/.gitignore index 6af2038..599f7d8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ dist .DS_Store *.log *.tsbuildinfo + +# Cloudflare Workers (OpenNext) +.open-next +.wrangler +.dev.vars +cloudflare-env.d.ts diff --git a/README.md b/README.md index 51ec601..9ecf0cb 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ the `event` prop rebuilds the canvas, because a different event is different inventory. Release any open hold before switching, otherwise those seats stay off the market on the previous event until they expire on their own. See `components/EventSwitcher.tsx` and the route handler at -`app/api/events/route.ts`, which serves static configuration and reads the real -catalogue with the Node server SDK when a secret key is present. +`app/api/events/route.ts`, which shows the events you configure and reads their +real names with the Node server SDK when a secret key is present. ### How do I offer best available seats for a group? @@ -233,6 +233,37 @@ and countdown. - [Node server SDK](https://docs.seatlayer.io/server-sdk/node/) - [seatlayer-sdk on GitHub](https://github.com/seatlayer/seatlayer-sdk) +## Hosting on Cloudflare + +The app also runs on Cloudflare Workers through the +[OpenNext adapter](https://opennext.js.org/cloudflare). `npm run build` stays a +plain Next.js build, so Vercel and `npm start` work as before. + +```sh +npm run preview # build for Workers and run it locally +npm run deploy # build for Workers and deploy with Wrangler +``` + +The worker is configured in `wrangler.jsonc` and `open-next.config.ts`. With +Cloudflare Workers Builds, use `npx opennextjs-cloudflare build` as the build +command and `npx opennextjs-cloudflare deploy` as the deploy command. Set the +`NEXT_PUBLIC_` variables as build variables, because Next.js writes them into +the bundle at build time, and set `SEATLAYER_SECRET_KEY` as a secret on the +worker. + +Two extras help when the examples are shown inside another page: + +- Add `?embed=1` to any route to hide the navigation and the route heading, so + the example sits cleanly in an iframe. +- `/html` serves the seat picker as a plain HTML page with the script tag + install. The page is `app/html/index.html`, and the route fills in its + `` and `` placeholders from your environment. + +CI builds every push. When the repository has `DEMO_EVENT_KEY` and +`DEMO_PUBLIC_KEY` secrets (and optionally `DEMO_SEASON_KEY`), it also starts the +app and loads every route with `scripts/check-routes.mjs`, failing on an HTTP +error or a console error. + ## License MIT. See [LICENSE](./LICENSE). diff --git a/app/api/events/route.ts b/app/api/events/route.ts index a9bb499..9d532ab 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -11,19 +11,20 @@ export interface EventOption { /** * The event list for the multiple events page. * - * Static configuration keeps the repository runnable with a single event. When - * SEATLAYER_SECRET_KEY is present the same route reads the real catalogue with - * the Node server SDK instead. A secret key only ever exists in a route handler - * like this one: it is never sent to the browser, never logged, and never put - * in a response. + * The route shows only the events you configure, never your whole account. + * Without a secret key it serves them with the placeholder names below. When + * SEATLAYER_SECRET_KEY is present it reads each event's real name with the + * Node server SDK. A secret key only ever exists in a route handler like this + * one: it is never sent to the browser, never logged, and never put in a + * response. * * Events API: https://docs.seatlayer.io/server-api/events/ * Node server SDK: https://docs.seatlayer.io/server-sdk/node/ */ const staticEvents: EventOption[] = [ { key: eventKey, name: "Opening night" }, - { key: process.env.NEXT_PUBLIC_SEATLAYER_EVENT_KEY_2 ?? eventKey, name: "Saturday matinee" }, - { key: process.env.NEXT_PUBLIC_SEATLAYER_EVENT_KEY_3 ?? eventKey, name: "Closing night" }, + { key: process.env.NEXT_PUBLIC_SEATLAYER_EVENT_KEY_2 || eventKey, name: "Saturday matinee" }, + { key: process.env.NEXT_PUBLIC_SEATLAYER_EVENT_KEY_3 || eventKey, name: "Closing night" }, ]; export async function GET() { @@ -37,11 +38,14 @@ export async function GET() { const { SeatLayer } = await import("@seatlayer/server"); const seatlayer = new SeatLayer(secretKey); - const events: EventOption[] = []; - for await (const event of seatlayer.events.listAll()) { - events.push({ key: event.key, name: event.name }); - if (events.length >= 12) break; - } + // Each configured key once, named as it is in SeatLayer. + const keys = [...new Set(staticEvents.map((event) => event.key))]; + const events: EventOption[] = await Promise.all( + keys.map(async (key) => { + const event = await seatlayer.events.retrieve(key); + return { key, name: event.meta.name }; + }), + ); return NextResponse.json({ events, source: "server-sdk" }); } catch { diff --git a/app/checkout-handoff/page.tsx b/app/checkout-handoff/page.tsx index 9b3b657..35de71d 100644 --- a/app/checkout-handoff/page.tsx +++ b/app/checkout-handoff/page.tsx @@ -40,8 +40,8 @@ export default function Page() { path runs only when SEATLAYER_SECRET_KEY is set.
  • - app/api/events/route.ts serves the event list, and reads the real - catalogue with the same key when it is present. + app/api/events/route.ts serves the events you configure, and reads + their real names with the same key when it is present.
  • components/SeatPickerFlow.tsx is the browser side that produces the diff --git a/app/globals.css b/app/globals.css index 492da11..94cbf31 100644 --- a/app/globals.css +++ b/app/globals.css @@ -32,6 +32,17 @@ body { padding: 24px 16px 48px; } +/* ?embed=1 hides the example's own navigation and heading. See app/layout.tsx. */ +[data-embed] .nav, +[data-embed] .intro { + display: none; +} + +[data-embed] .page { + max-width: none; + padding: 0; +} + h1 { font-size: 1.6rem; margin: 0 0 4px; diff --git a/app/html/index.html b/app/html/index.html new file mode 100644 index 0000000..8b0d3cf --- /dev/null +++ b/app/html/index.html @@ -0,0 +1,49 @@ + + + + + + Seat picker with a script tag + + + +
    +

    + + + + + + diff --git a/app/html/route.ts b/app/html/route.ts new file mode 100644 index 0000000..0ab0278 --- /dev/null +++ b/app/html/route.ts @@ -0,0 +1,40 @@ +import template from "./index.html"; +import { eventKey, publicKey } from "@/lib/config"; + +export const dynamic = "force-dynamic"; + +/** + * The same seat picker as a plain HTML page, with no React and no build step. + * + * app/html/index.html is the page exactly as you would copy it into your own + * site. This route only swaps its two placeholders for the keys in the + * environment when a request arrives. + * + * Script tag install: https://docs.seatlayer.io/buyer-sdk/install/ + */ +export function GET() { + if (!isKey(eventKey) || !isKey(publicKey)) { + return html( + "Add your event keys" + + "

    Set NEXT_PUBLIC_SEATLAYER_EVENT_KEY and NEXT_PUBLIC_SEATLAYER_PUBLIC_KEY, " + + "then restart the server.

    ", + ); + } + + return html( + template + .replace(//g, eventKey) + .replace(//g, publicKey), + ); +} + +/** Keys are plain ids, so anything else is refused rather than written into a script. */ +function isKey(value: string) { + return /^[A-Za-z0-9_-]+$/.test(value); +} + +function html(body: string) { + return new Response(body, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); +} diff --git a/app/layout.tsx b/app/layout.tsx index af4d6b4..d20ef29 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,9 +8,22 @@ export const metadata: Metadata = { "Seat selection, holds, season tickets, multiple events and best available in a Next.js App Router application.", }; +/** + * Add ?embed=1 to any route to show only the example itself, without the + * navigation and the route heading, for use inside an iframe. The script runs + * before the first paint, so the navigation never flashes in or out. + */ +const embedScript = `if (new URLSearchParams(location.search).get("embed") === "1") { + document.documentElement.dataset.embed = ""; +}`; + export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + // The embed script sets an attribute on before React hydrates. + + +