diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc731a6..a766178 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. + VITE_SEATLAYER_EVENT_KEY: ${{ secrets.DEMO_EVENT_KEY }} + VITE_SEATLAYER_PUBLIC_KEY: ${{ secrets.DEMO_PUBLIC_KEY }} + VITE_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.VITE_SEATLAYER_EVENT_KEY != '' && env.VITE_SEATLAYER_PUBLIC_KEY != '' + run: | + npm install --no-save playwright@1.63.0 + npx playwright install --with-deps chromium + + - name: Load every route + if: env.VITE_SEATLAYER_EVENT_KEY != '' && env.VITE_SEATLAYER_PUBLIC_KEY != '' + run: | + npm run preview -- --port 4173 --strictPort & + for i in $(seq 1 30); do curl -sf http://localhost:4173 > /dev/null && break; sleep 2; done + node scripts/check-routes.mjs diff --git a/README.md b/README.md index 6d92eed..d3611d7 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,26 @@ countdown. - [Server SDKs](https://docs.seatlayer.io/server-sdk/) - [seatlayer-sdk on GitHub](https://github.com/seatlayer/seatlayer-sdk) +## Hosting on Cloudflare + +The build output in `dist` is a static site, so it runs on Cloudflare Pages as +it is. Use `npm run build` as the build command and `dist` as the output +directory. Set the `VITE_` variables as build variables, because Vite writes +them into the bundle at build time. + +The routes are handled in the browser by React Router. Pages serves +`index.html` for any path that is not a file when the site has no top level +`404.html`, so deep links such as `/seat-picker` work without a `_redirects` +file. + +Add `?embed=1` to any route to hide the navigation and the route heading, so +the example sits cleanly in an iframe. + +CI builds every push. When the repository has `DEMO_EVENT_KEY` and +`DEMO_PUBLIC_KEY` secrets (and optionally `DEMO_SEASON_KEY`), it also serves the +build with `npm run preview` 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/scripts/check-routes.mjs b/scripts/check-routes.mjs new file mode 100644 index 0000000..8d4315b --- /dev/null +++ b/scripts/check-routes.mjs @@ -0,0 +1,52 @@ +/** + * Loads every route in a real browser and fails on an HTTP error, an uncaught + * exception or a console error. Each route is also loaded with ?embed=1, where + * the navigation must be hidden. + * + * BASE_URL=http://localhost:4173 node scripts/check-routes.mjs + * + * CI runs it against `npm run preview` when the demo keys are available. + */ +import { chromium } from "playwright"; + +const baseUrl = process.env.BASE_URL ?? "http://localhost:4173"; + +const routes = ["/", "/seat-picker", "/season", "/events", "/best-available", "/control-room"]; + +const browser = await chromium.launch(); +const failures = []; + +for (const route of routes) { + for (const path of [route, `${route}?embed=1`]) { + const page = await browser.newPage(); + const problems = []; + page.on("pageerror", (error) => problems.push(`uncaught: ${error.message}`)); + page.on("console", (message) => { + if (message.type() === "error") problems.push(`console: ${message.text()}`); + }); + + const response = await page.goto(baseUrl + path, { waitUntil: "load" }); + if (!response || !response.ok()) { + problems.push(`HTTP ${response ? response.status() : "no response"}`); + } + + // Give the seat map time to fetch its chart and render. + await page.waitForTimeout(4000); + + if (path.endsWith("?embed=1") && (await page.locator(".nav").isVisible())) { + problems.push("navigation is visible in embed mode"); + } + + console.log(`${problems.length ? "FAIL" : "ok "} ${path}`); + for (const problem of problems) console.log(` ${problem}`); + if (problems.length) failures.push(path); + await page.close(); + } +} + +await browser.close(); + +if (failures.length) { + console.error(`\n${failures.length} route(s) failed.`); + process.exit(1); +} diff --git a/src/App.tsx b/src/App.tsx index 4d3668d..9de3f38 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { NavLink, Outlet } from "react-router-dom"; +import { NavLink, Outlet, useSearchParams } from "react-router-dom"; const routes = [ { to: "/", label: "Single event", end: true }, @@ -10,20 +10,27 @@ const routes = [ ]; export default function App() { + // Add ?embed=1 to any route to show only the example itself, without the + // navigation and the route heading, for use inside an iframe. + const [searchParams] = useSearchParams(); + const embed = searchParams.get("embed") === "1"; + return ( -