Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,29 @@ 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
with:
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ dist
.DS_Store
*.log
*.tsbuildinfo

# Cloudflare Workers (OpenNext)
.open-next
.wrangler
.dev.vars
cloudflare-env.d.ts
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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
`<YOUR_EVENT_KEY>` and `<YOUR_PUBLIC_KEY>` 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).
Expand Down
28 changes: 16 additions & 12 deletions app/api/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions app/checkout-handoff/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export default function Page() {
path runs only when <code>SEATLAYER_SECRET_KEY</code> is set.
</li>
<li>
<code>app/api/events/route.ts</code> serves the event list, and reads the real
catalogue with the same key when it is present.
<code>app/api/events/route.ts</code> serves the events you configure, and reads
their real names with the same key when it is present.
</li>
<li>
<code>components/SeatPickerFlow.tsx</code> is the browser side that produces the
Expand Down
11 changes: 11 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions app/html/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Seat picker with a script tag</title>
<style>
body {
margin: 0;
padding: 16px;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
#picker {
width: 100%;
height: 640px;
}
</style>
</head>
<body>
<div id="picker"></div>
<p id="status"></p>

<!-- The buyer SDK as one script. It adds a global called seatlayer. -->
<script src="https://cdn.seatlayer.io/seatlayer-js@0/seatlayer-buyer.js"></script>
<script>
const status = document.getElementById("status");

const picker = new seatlayer.SeatPicker({
container: "#picker",
event: "<YOUR_EVENT_KEY>",
publicKey: "<YOUR_PUBLIC_KEY>",
// The buyer pressed checkout. Send only the hold id to your server,
// which reads the real prices back from SeatLayer before charging.
onCheckout: async (_hold, _seats, handoff) => {
status.textContent = "Hold id: " + handoff.holdId;
const response = await fetch("/api/hold", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ holdId: handoff.holdId }),
});
const result = await response.json();
console.log("Continue to checkout with hold id:", handoff.holdId, result);
},
});

picker.render();
</script>
</body>
</html>
40 changes: 40 additions & 0 deletions app/html/route.ts
Original file line number Diff line number Diff line change
@@ -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(
"<!doctype html><title>Add your event keys</title>" +
"<p>Set NEXT_PUBLIC_SEATLAYER_EVENT_KEY and NEXT_PUBLIC_SEATLAYER_PUBLIC_KEY, " +
"then restart the server.</p>",
);
}

return html(
template
.replace(/<YOUR_EVENT_KEY>/g, eventKey)
.replace(/<YOUR_PUBLIC_KEY>/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" },
});
}
15 changes: 14 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<html lang="en">
// The embed script sets an attribute on <html> before React hydrates.
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: embedScript }} />
</head>
<body>
<div className="page">
<Nav />
Expand Down
4 changes: 2 additions & 2 deletions components/EventSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ export function EventSwitcher() {
</div>
{hold ? <p className="muted small">Hold id: {hold.holdId}</p> : null}
<p className="muted">
The list comes from <code>app/api/events/route.ts</code>, which serves static
configuration and reads the real catalogue with the server SDK when a secret key is
The list comes from <code>app/api/events/route.ts</code>, which shows the events
you configure and reads their real names with the server SDK when a secret key is
present.
</p>
</aside>
Expand Down
4 changes: 4 additions & 0 deletions components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export function Nav() {
{route.label}
</Link>
))}
{/* A route handler rather than a page, so a plain link loads it. */}
<a href="/html" className="nav-link">
Plain HTML
</a>
</nav>
);
}
1 change: 1 addition & 0 deletions next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
3 changes: 3 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const nextConfig = {
...(config.ignoreWarnings ?? []),
{ module: /@seatlayer[\\/]js/, message: /Critical dependency/ },
];
// Lets app/html/route.ts import app/html/index.html as a string, so the
// template stays a plain HTML file you can copy as it is.
config.module.rules.push({ test: /\.html$/, type: "asset/source" });
return config;
},
};
Expand Down
13 changes: 13 additions & 0 deletions open-next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache";

/**
* OpenNext settings for Cloudflare Workers.
*
* The example has no revalidation, so prerendered pages are served straight
* from Workers static assets and no cache bucket is needed.
* https://opennext.js.org/cloudflare/caching
*/
export default defineCloudflareConfig({
incrementalCache: staticAssetsIncrementalCache,
});
Loading
Loading