@semics-tech/emdash-events-suite 0.0.0-stage → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 semics
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,83 @@
1
- # Temporary Holding Version
1
+ # @semics-tech/emdash-events-suite
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Links [semics Events](../events) and [semics E-sign](../esign) on one EmDash site: one config call,
4
+ and `<BookingWithWaiver>`, a booking form whose waivers are signed inline, on the same visit.
5
+
6
+ The suite adds no plugin code of its own: no capabilities, storage or routes. It wires the two products
7
+ together through their public routes. Waivers are an Events Pro feature; with the Free Events core
8
+ the same form makes a plain booking.
9
+
10
+ **Documentation:** [install](https://github.com/semics-uk/emdash-plugins/blob/main/apps/docs/pages/install.md)
11
+ and [waivers](https://github.com/semics-uk/emdash-plugins/blob/main/apps/docs/pages/waivers.md).
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pnpm add @semics-tech/emdash-events @semics-tech/emdash-esign @semics-tech/emdash-events-suite @emdash-cms/sandbox-workerd
17
+ ```
18
+
19
+ ```js
20
+ // astro.config.mjs
21
+ import { eventsSuite } from "@semics-tech/emdash-events-suite";
22
+ import { defineConfig } from "astro/config";
23
+ import emdash from "emdash/astro";
24
+
25
+ const suite = eventsSuite();
26
+
27
+ export default defineConfig({
28
+ output: "server",
29
+ integrations: [
30
+ ...suite.integrations,
31
+ emdash({
32
+ siteUrl: "https://events.example.com",
33
+ sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox",
34
+ sandboxed: suite.sandboxed,
35
+ plugins: suite.plugins,
36
+ }),
37
+ ],
38
+ });
39
+ ```
40
+
41
+ `eventsSuite()` returns both sandboxed cores, both UI descriptors, and the Astro integrations of both
42
+ products plus the suite's waiver page (`/_events/waiver`). Each part gets the plugin id of each core it
43
+ calls, so nothing assumes a slug. Pass a Pro build as `eventsSuite({ events: <the Events Pro export> })`, or,
44
+ for cores installed from the registry, their ids:
45
+
46
+ ```js
47
+ import { eventsSuite } from "@semics-tech/emdash-events-suite";
48
+
49
+ // Cores installed from the registry: only the ids, which start with r_.
50
+ export const suite = eventsSuite({ events: { pluginId: "r_events_id" }, esign: { pluginId: "r_esign_id" } });
51
+ ```
52
+
53
+ Pair the two cores in the admin: create an integration client in E-sign (Integrations), paste its
54
+ pairing string into the Events settings, and allow `https://<your site>/booking` as a return URL.
55
+
56
+ ## Book with a waiver
57
+
58
+ ```astro
59
+ ---
60
+ import { getEmDashEntry } from "emdash";
61
+ import BookingWithWaiver from "@semics-tech/emdash-events-suite/astro/BookingWithWaiver.astro";
62
+
63
+ const { entry } = await getEmDashEntry("events", Astro.params.slug ?? "");
64
+ ---
65
+ {entry && <BookingWithWaiver event={entry.data.id} />}
66
+ ```
67
+
68
+ The form lists the buyer and one row per attendee, each with an "under 18" control (`seats`, default
69
+ 4, at most 10). Names and emails with control characters are refused before anything is booked.
70
+ It posts to `/_events/waiver`, which reserves the seats and passes the attendees on. When the event
71
+ requires a waiver (Events Pro), each signing intent is redeemed with E-sign: the buyer signs inline,
72
+ for themselves and as guardian for every attendee under 18, one ceremony after another on that page;
73
+ other adults are emailed their own waiver. Each receipt is forwarded to Events as the ceremony ends,
74
+ and the buyer lands on `/booking`, confirmed. It works without JavaScript.
75
+
76
+ `/_events/waiver` sends E-sign's ceremony Content-Security-Policy, `Referrer-Policy: no-referrer` and
77
+ `no-store`. Signing tokens never appear in a URL: they are kept in an HttpOnly, SameSite=Strict cookie
78
+ scoped to that page.
79
+
80
+ Paid waiver events: when continue answers with a payment step, the browser goes to the
81
+ provider's checkout.
82
+
83
+ The suite's own pages are English only for now.
@@ -0,0 +1,72 @@
1
+ import type { PluginDescriptor } from "emdash";
2
+ /**
3
+ * A core the suite links: a sandboxed descriptor to load from npm (the product package's default
4
+ * export, or a Pro build of the same slug), or `{ pluginId }` for a core installed from the
5
+ * registry, whose id is an `r_…` value the operator copies from the admin.
6
+ */
7
+ export type SuiteCore = PluginDescriptor | {
8
+ pluginId: string;
9
+ };
10
+ export interface EventsSuiteOptions {
11
+ /** Default: the Free Events core of `@semics-tech/emdash-events`. */
12
+ events?: SuiteCore;
13
+ /** Default: the Free E-sign core of `@semics-tech/emdash-esign`. */
14
+ esign?: SuiteCore;
15
+ }
16
+ /** The slice of Astro's integration API the injected pages need (no `astro` dependency). */
17
+ interface SetupArgs {
18
+ injectRoute(route: {
19
+ pattern: string;
20
+ entrypoint: string;
21
+ prerender?: boolean;
22
+ }): void;
23
+ updateConfig(config: {
24
+ vite: {
25
+ plugins: unknown[];
26
+ };
27
+ }): void;
28
+ }
29
+ export interface SuiteAstroIntegration {
30
+ name: string;
31
+ hooks: {
32
+ "astro:config:setup": (args: SetupArgs) => void;
33
+ };
34
+ }
35
+ export interface EventsSuite {
36
+ /** Spread into `emdash({ sandboxed })`: the cores loaded from npm (none for registry installs). */
37
+ sandboxed: PluginDescriptor[];
38
+ /** Spread into `emdash({ plugins })`: both native UI descriptors. */
39
+ plugins: PluginDescriptor[];
40
+ /** Spread into Astro's `integrations`: both products' pages and the suite's waiver page. */
41
+ integrations: SuiteAstroIntegration[];
42
+ /** The id each core runs under on this site. */
43
+ pluginIds: {
44
+ events: string;
45
+ esign: string;
46
+ };
47
+ }
48
+ /** The suite's own page: the inline signing step of <BookingWithWaiver>. */
49
+ export declare const WAIVER_ROUTE = "/_events/waiver";
50
+ /** The suite's catalogue page: where the Events panel's Refresh templates link lands (T066). */
51
+ export declare const TEMPLATES_ROUTE = "/_events/templates";
52
+ /**
53
+ * Injects `/_events/waiver` (the booking and signing steps of `<BookingWithWaiver>`) and gives it,
54
+ * through a virtual module, the id of each core. `eventsSuite()` adds it for you.
55
+ */
56
+ export declare function suiteIntegration(ids: {
57
+ eventsPluginId: string;
58
+ esignPluginId: string;
59
+ }): SuiteAstroIntegration;
60
+ /**
61
+ * Events and E-sign on one site (SX-FR-01). Every part is told the id of each core it calls, taken
62
+ * from the descriptor or the registry id given here, never assumed:
63
+ *
64
+ * ```js
65
+ * const suite = eventsSuite(); // or eventsSuite({ events: eventsPro(), esign: { pluginId: "r_…" } })
66
+ * export default defineConfig({
67
+ * integrations: [...suite.integrations, emdash({ sandboxed: suite.sandboxed, plugins: suite.plugins })],
68
+ * });
69
+ * ```
70
+ */
71
+ export declare function eventsSuite(options?: EventsSuiteOptions): EventsSuite;
72
+ export default eventsSuite;
@@ -0,0 +1,88 @@
1
+ import esignCore from "@semics-tech/emdash-esign";
2
+ import { esignIntegration } from "@semics-tech/emdash-esign/integration";
3
+ import { esignUi } from "@semics-tech/emdash-esign/ui";
4
+ import eventsCore from "@semics-tech/emdash-events";
5
+ import { eventsIntegration } from "@semics-tech/emdash-events/integration";
6
+ import { eventsUi } from "@semics-tech/emdash-events/ui";
7
+ /** A plugin id is a route segment (`/_emdash/api/plugins/<id>/…`): a slug or a registry `r_…` id. */
8
+ const PLUGIN_ID = /^[A-Za-z0-9_-]{1,128}$/;
9
+ function resolveCore(core) {
10
+ const descriptor = "id" in core ? core : undefined;
11
+ const id = descriptor ? descriptor.id : core.pluginId;
12
+ if (typeof id !== "string" || !PLUGIN_ID.test(id)) {
13
+ throw new Error(`eventsSuite: invalid plugin id ${JSON.stringify(id)} (letters, digits, "_" and "-" only)`);
14
+ }
15
+ return descriptor ? { id, descriptor } : { id };
16
+ }
17
+ const VIRTUAL = "virtual:semics-events-suite/config";
18
+ const RESOLVED = `\0${VIRTUAL}`;
19
+ /** The suite's own page: the inline signing step of <BookingWithWaiver>. */
20
+ export const WAIVER_ROUTE = "/_events/waiver";
21
+ /** The suite's catalogue page: where the Events panel's Refresh templates link lands (T066). */
22
+ export const TEMPLATES_ROUTE = "/_events/templates";
23
+ /** What Events' booking page runs for a booking waiting for signatures (T066). */
24
+ const RECONCILE_MODULE = "@semics-tech/emdash-events-suite/astro/lib/reconcile";
25
+ /**
26
+ * Injects `/_events/waiver` (the booking and signing steps of `<BookingWithWaiver>`) and gives it,
27
+ * through a virtual module, the id of each core. `eventsSuite()` adds it for you.
28
+ */
29
+ export function suiteIntegration(ids) {
30
+ const config = [
31
+ `export const eventsPluginId = ${JSON.stringify(ids.eventsPluginId)};`,
32
+ `export const esignPluginId = ${JSON.stringify(ids.esignPluginId)};`,
33
+ ].join("\n");
34
+ return {
35
+ name: "semics-events-suite",
36
+ hooks: {
37
+ "astro:config:setup": ({ injectRoute, updateConfig }) => {
38
+ injectRoute({
39
+ pattern: WAIVER_ROUTE,
40
+ entrypoint: "@semics-tech/emdash-events-suite/astro/WaiverPage.astro",
41
+ prerender: false,
42
+ });
43
+ injectRoute({
44
+ pattern: TEMPLATES_ROUTE,
45
+ entrypoint: "@semics-tech/emdash-events-suite/astro/TemplatesPage.astro",
46
+ prerender: false,
47
+ });
48
+ updateConfig({
49
+ vite: {
50
+ plugins: [
51
+ {
52
+ name: "semics-events-suite-config",
53
+ resolveId: (id) => (id === VIRTUAL ? RESOLVED : undefined),
54
+ load: (id) => (id === RESOLVED ? config : undefined),
55
+ },
56
+ ],
57
+ },
58
+ });
59
+ },
60
+ },
61
+ };
62
+ }
63
+ /**
64
+ * Events and E-sign on one site (SX-FR-01). Every part is told the id of each core it calls, taken
65
+ * from the descriptor or the registry id given here, never assumed:
66
+ *
67
+ * ```js
68
+ * const suite = eventsSuite(); // or eventsSuite({ events: eventsPro(), esign: { pluginId: "r_…" } })
69
+ * export default defineConfig({
70
+ * integrations: [...suite.integrations, emdash({ sandboxed: suite.sandboxed, plugins: suite.plugins })],
71
+ * });
72
+ * ```
73
+ */
74
+ export function eventsSuite(options = {}) {
75
+ const events = resolveCore(options.events ?? eventsCore);
76
+ const esign = resolveCore(options.esign ?? esignCore);
77
+ return {
78
+ sandboxed: [events.descriptor, esign.descriptor].filter((d) => d !== undefined),
79
+ plugins: [eventsUi(), esignUi()],
80
+ integrations: [
81
+ eventsIntegration({ pluginId: events.id, esignPluginId: esign.id, reconcile: RECONCILE_MODULE }),
82
+ esignIntegration({ pluginId: esign.id }),
83
+ suiteIntegration({ eventsPluginId: events.id, esignPluginId: esign.id }),
84
+ ],
85
+ pluginIds: { events: events.id, esign: esign.id },
86
+ };
87
+ }
88
+ export default eventsSuite;
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@semics-tech/emdash-events-suite",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0-rc.0",
4
+ "type": "module",
5
+ "description": "Links semics Events and E-sign on one EmDash site: one config call and a booking flow with inline waiver signing",
6
+ "license": "MIT",
7
+ "main": "dist/ts/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/ts/index.js",
11
+ "types": "./dist/ts/index.d.ts"
12
+ },
13
+ "./astro": "./src/astro/index.js",
14
+ "./astro/BookingWithWaiver.astro": "./src/astro/BookingWithWaiver.astro",
15
+ "./astro/WaiverPage.astro": "./src/astro/WaiverPage.astro",
16
+ "./astro/TemplatesPage.astro": "./src/astro/TemplatesPage.astro",
17
+ "./astro/lib/*": "./src/astro/lib/*.ts"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src/astro"
22
+ ],
23
+ "dependencies": {
24
+ "@semics-tech/emdash-esign": "1.0.0-rc.0",
25
+ "@semics-tech/emdash-events": "1.0.0-rc.0"
26
+ },
27
+ "peerDependencies": {
28
+ "emdash": ">=1.2.0 <2"
29
+ },
30
+ "devDependencies": {
31
+ "@vitest/coverage-v8": "4.1.12",
32
+ "emdash": "1.2.0",
33
+ "typescript": "^5.9.0",
34
+ "vitest": "^4.1.12"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/semics-uk/emdash-plugins",
39
+ "directory": "packages/events-suite"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run",
45
+ "test:coverage": "vitest run --coverage --reporter=dot",
46
+ "test:verify": "vitest run --reporter=dot ${CI:+--coverage}",
47
+ "verify": "pnpm run typecheck && pnpm run test:verify && pnpm run build"
48
+ }
6
49
  }
@@ -0,0 +1,295 @@
1
+ ---
2
+ // <BookingWithWaiver event={entryId} /> (SX-FR-02): books an event whose tickets need a signed waiver,
3
+ // in one flow: this form → `/_events/waiver` (reserve, attendees, signing intents) → the waiver
4
+ // signed inline on that page → back to the booking page, confirmed. A plain form post: no JavaScript.
5
+ //
6
+ // One row per attendee, each with an "under 18" control: the buyer signs for themselves and, as
7
+ // guardian, for every attendee under 18; other adults sign their own waiver from an email. Without
8
+ // Events Pro (waivers are Pro) the same form makes a plain booking. All text is rendered as text.
9
+ import { eventsPluginId } from "virtual:semics-events-suite/config";
10
+ import { callPublicRoute } from "@semics-tech/emdash-events/astro/lib/call";
11
+ import { formatMoney, newIdempotencyKey, type PublicEvent } from "@semics-tech/emdash-events/astro/lib/widget";
12
+
13
+ import {
14
+ DEFAULT_SEATS,
15
+ MAX_SEATS,
16
+ readWaiverState,
17
+ safeReturnPath,
18
+ seatField,
19
+ WAIVER_MESSAGES,
20
+ WAIVER_PATH,
21
+ waiverAnchor,
22
+ } from "./lib/form";
23
+
24
+ interface Props {
25
+ /** The event's content entry id. */
26
+ event: string;
27
+ /** The `public/event` answer, when the page already has it. */
28
+ data?: PublicEvent;
29
+ heading?: string;
30
+ /** Attendee rows to offer (1-10, default 4). */
31
+ seats?: number;
32
+ locale?: string;
33
+ }
34
+
35
+ const { event: eventId, heading = "Book with waiver", locale = "en-GB" } = Astro.props;
36
+ const seats = Math.min(MAX_SEATS, Math.max(1, Math.trunc(Astro.props.seats ?? DEFAULT_SEATS)));
37
+ let data = Astro.props.data;
38
+ if (!data) {
39
+ const result = await callPublicRoute<PublicEvent>(Astro, { path: "public/event", pluginId: eventsPluginId, body: { eventId } });
40
+ if (result.ok) data = result.data;
41
+ }
42
+
43
+ const id = waiverAnchor(eventId);
44
+ const state = readWaiverState(Astro.url, eventId);
45
+ const alert = state.code ? (WAIVER_MESSAGES[state.code] ?? WAIVER_MESSAGES.INTERNAL) : "";
46
+ const bad = (field: string) => (state.fields.includes(field) ? "true" : undefined);
47
+ const returnTo = safeReturnPath(`${Astro.url.pathname}${Astro.url.search}`, Astro.url.origin) ?? "";
48
+ const ticketTypes = (data?.ticketTypes ?? []).filter((t) => t.available !== 0);
49
+ const occurrences = data?.occurrences ?? [];
50
+ const onSale = data?.status === "on_sale" && ticketTypes.length > 0;
51
+ const siteKey = data?.turnstileSiteKey ?? "";
52
+ const price = (minor: number) => formatMoney(minor, data?.currency ?? "GBP", locale);
53
+ const when = (iso: string) => {
54
+ const options = { dateStyle: "full", timeStyle: "short" } as const;
55
+ try {
56
+ return new Intl.DateTimeFormat(locale, { ...options, timeZone: data?.timezone ?? "UTC" }).format(new Date(iso));
57
+ } catch {
58
+ return new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" }).format(new Date(iso));
59
+ }
60
+ };
61
+ // Attendee rows are only for an event whose waiver is required (`public/event` says so, T066); any other
62
+ // event is booked by ticket type and quantity, as a plain booking.
63
+ const needsWaiver = data?.waiver === true;
64
+ const rows = needsWaiver ? Array.from({ length: seats }, (_, i) => i + 1) : [];
65
+ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
66
+ ---
67
+ <section id={id} class="semics-events semics-waiver-widget" aria-labelledby={`${id}-title`} data-testid="booking-with-waiver">
68
+ <h2 id={`${id}-title`}>{heading}</h2>
69
+ {
70
+ !onSale ? (
71
+ <p data-testid="booking-unavailable">Booking is not available for this event right now.</p>
72
+ ) : (
73
+ <form method="post" action={WAIVER_PATH}>
74
+ <p class="semics-form-alert" role="alert" data-testid="waiver-alert" hidden={!alert}>{alert}</p>
75
+ <input type="hidden" name="intent" value="book" />
76
+ <input type="hidden" name="eventId" value={eventId} />
77
+ <input type="hidden" name="returnTo" value={returnTo} />
78
+ <input type="hidden" name="idempotencyKey" value={newIdempotencyKey()} />
79
+
80
+ {occurrences.length > 0 && (
81
+ <div class="semics-field">
82
+ <input type="hidden" name="needsOccurrence" value="1" />
83
+ <label for={`${id}-occurrence`}>Date</label>
84
+ <select id={`${id}-occurrence`} name="occurrenceId" required aria-invalid={bad("occurrence")}>
85
+ <option value="">Choose a date</option>
86
+ {occurrences.map((o) => (
87
+ <option value={o.id} disabled={o.available === 0}>
88
+ {when(o.startsAt)}
89
+ {o.available === 0 ? " (sold out)" : ""}
90
+ </option>
91
+ ))}
92
+ </select>
93
+ </div>
94
+ )}
95
+
96
+ <fieldset>
97
+ <legend>You</legend>
98
+ <div class="semics-field">
99
+ <label for={`${id}-name`}>Your name</label>
100
+ <input id={`${id}-name`} name="name" type="text" autocomplete="name" required maxlength="200" aria-invalid={bad("name")} />
101
+ </div>
102
+ <div class="semics-field">
103
+ <label for={`${id}-email`}>Your email</label>
104
+ <input id={`${id}-email`} name="email" type="email" autocomplete="email" required maxlength="254" aria-invalid={bad("email")} />
105
+ </div>
106
+ </fieldset>
107
+
108
+ {!needsWaiver && (
109
+ <fieldset data-testid="waiver-plain">
110
+ <legend>Tickets</legend>
111
+ <input type="hidden" name="plain" value="1" />
112
+ <div class="semics-field">
113
+ <label for={`${id}-plain-type`}>Ticket</label>
114
+ <select id={`${id}-plain-type`} name="ticketTypeId" aria-invalid={bad("tickets")}>
115
+ {ticketTypes.map((t) => (
116
+ <option value={t.id}>{t.name} ({price(t.priceMinor)})</option>
117
+ ))}
118
+ </select>
119
+ </div>
120
+ <div class="semics-field">
121
+ <label for={`${id}-plain-qty`}>How many</label>
122
+ <input id={`${id}-plain-qty`} name="qty" type="number" min="1" max={MAX_SEATS} value="1" required aria-invalid={bad("tickets")} />
123
+ </div>
124
+ </fieldset>
125
+ )}
126
+
127
+ {needsWaiver && (
128
+ <p class="semics-note" id={`${id}-who`}>
129
+ List everyone attending, including you if you are going. You sign the waiver now, for yourself and
130
+ for anyone under 18. Other adults need their own email address: they are sent their waiver to sign.
131
+ </p>
132
+ )}
133
+ {bad("attendees") && <p class="semics-field-error" data-testid="waiver-attendees-error">Add at least one attendee, and an email for each other adult.</p>}
134
+
135
+ {rows.map((n) => (
136
+ <fieldset class="semics-attendee" data-testid="waiver-attendee" aria-describedby={`${id}-who`}>
137
+ <legend>Attendee {n}</legend>
138
+ {single ? (
139
+ <input type="hidden" name={seatField(n, "ticketTypeId")} value={single.id} />
140
+ ) : (
141
+ <div class="semics-field">
142
+ <label for={`${id}-${n}-type`}><span class="semics-sr">Attendee {n} </span>Ticket</label>
143
+ <select id={`${id}-${n}-type`} name={seatField(n, "ticketTypeId")} aria-invalid={bad(seatField(n, "ticketTypeId"))}>
144
+ {ticketTypes.map((t) => (
145
+ <option value={t.id}>{t.name} ({price(t.priceMinor)})</option>
146
+ ))}
147
+ </select>
148
+ </div>
149
+ )}
150
+ <div class="semics-field">
151
+ <label for={`${id}-${n}-name`}><span class="semics-sr">Attendee {n} </span>Name</label>
152
+ <input id={`${id}-${n}-name`} name={seatField(n, "name")} type="text" maxlength="200" required={n === 1 || undefined} aria-invalid={bad(seatField(n, "name"))} />
153
+ </div>
154
+ <div class="semics-field">
155
+ <label for={`${id}-${n}-email`}><span class="semics-sr">Attendee {n} </span>Email (optional)</label>
156
+ <input id={`${id}-${n}-email`} name={seatField(n, "email")} type="email" maxlength="254" aria-invalid={bad(seatField(n, "email"))} />
157
+ </div>
158
+ <div class="semics-check">
159
+ <input id={`${id}-${n}-minor`} name={seatField(n, "minor")} type="checkbox" />
160
+ <label for={`${id}-${n}-minor`}><span class="semics-sr">Attendee {n} is </span>Under 18</label>
161
+ </div>
162
+ </fieldset>
163
+ ))}
164
+
165
+ {needsWaiver && single && <p class="semics-note">{single.name}: {price(single.priceMinor)} per attendee.</p>}
166
+
167
+ {siteKey && (
168
+ <>
169
+ <div class="cf-turnstile" data-sitekey={siteKey}></div>
170
+ {/* Compact below 300 px so a 320 px screen never scrolls sideways (WCAG 1.4.10). */}
171
+ <script is:inline>(function (s) { var t = s && s.previousElementSibling; if (t && t.clientWidth < 300) t.setAttribute("data-size", "compact"); })(document.currentScript);</script>
172
+ <noscript><p class="semics-note">This form uses a security check that needs JavaScript.</p></noscript>
173
+ <script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
174
+ </>
175
+ )}
176
+
177
+ <button type="submit">Book</button>
178
+ </form>
179
+ )
180
+ }
181
+ </section>
182
+
183
+ <style>
184
+ .semics-waiver-widget {
185
+ --_fg: var(--events-fg, #1a1a1a);
186
+ --_muted: var(--events-muted, #4a4a4a);
187
+ --_bg: var(--events-bg, #ffffff);
188
+ --_border: var(--events-border, #6b6b6b);
189
+ --_accent: var(--events-accent, #0b57d0);
190
+ --_accent-fg: var(--events-accent-fg, #ffffff);
191
+ --_error: var(--events-error, #b3261e);
192
+ --_radius: var(--events-radius, 6px);
193
+ box-sizing: border-box;
194
+ max-inline-size: 36rem;
195
+ padding: 1rem;
196
+ border: 1px solid var(--_border);
197
+ border-radius: var(--_radius);
198
+ background: var(--_bg);
199
+ color: var(--_fg);
200
+ overflow-wrap: anywhere;
201
+ }
202
+ .semics-waiver-widget *,
203
+ .semics-waiver-widget *::before,
204
+ .semics-waiver-widget *::after {
205
+ box-sizing: border-box;
206
+ }
207
+ .semics-waiver-widget h2 {
208
+ margin: 0 0 0.75rem;
209
+ font-size: 1.25rem;
210
+ }
211
+ .semics-waiver-widget form {
212
+ display: grid;
213
+ gap: 0.75rem;
214
+ }
215
+ .semics-waiver-widget fieldset {
216
+ display: grid;
217
+ gap: 0.5rem;
218
+ margin: 0;
219
+ padding: 0.5rem 0.75rem;
220
+ border: 1px solid var(--_border);
221
+ border-radius: var(--_radius);
222
+ min-inline-size: 0;
223
+ }
224
+ .semics-field {
225
+ display: grid;
226
+ gap: 0.25rem;
227
+ }
228
+ .semics-check {
229
+ display: flex;
230
+ align-items: center;
231
+ gap: 0.5rem;
232
+ }
233
+ .semics-waiver-widget input:not([type="checkbox"]),
234
+ .semics-waiver-widget select,
235
+ .semics-waiver-widget button {
236
+ font: inherit;
237
+ min-block-size: 2.75rem;
238
+ max-inline-size: 100%;
239
+ }
240
+ .semics-waiver-widget input:not([type="checkbox"]),
241
+ .semics-waiver-widget select {
242
+ inline-size: 100%;
243
+ padding: 0.375rem 0.5rem;
244
+ border: 1px solid var(--_border);
245
+ border-radius: var(--_radius);
246
+ background: var(--_bg);
247
+ color: var(--_fg);
248
+ }
249
+ .semics-waiver-widget input[type="checkbox"] {
250
+ inline-size: 1.25rem;
251
+ block-size: 1.25rem;
252
+ }
253
+ .semics-waiver-widget [aria-invalid="true"] {
254
+ border-color: var(--_error);
255
+ outline: 1px solid var(--_error);
256
+ }
257
+ .semics-note {
258
+ margin: 0;
259
+ color: var(--_muted);
260
+ }
261
+ .semics-field-error,
262
+ .semics-form-alert {
263
+ margin: 0;
264
+ color: var(--_error);
265
+ }
266
+ .semics-form-alert {
267
+ padding: 0.5rem 0.75rem;
268
+ border: 1px solid var(--_error);
269
+ border-radius: var(--_radius);
270
+ }
271
+ .semics-sr {
272
+ position: absolute;
273
+ inline-size: 1px;
274
+ block-size: 1px;
275
+ overflow: hidden;
276
+ clip-path: inset(50%);
277
+ white-space: nowrap;
278
+ }
279
+ .semics-waiver-widget button {
280
+ justify-self: start;
281
+ padding: 0.5rem 1.5rem;
282
+ border: 0;
283
+ border-radius: var(--_radius);
284
+ background: var(--_accent);
285
+ color: var(--_accent-fg);
286
+ cursor: pointer;
287
+ }
288
+ .semics-waiver-widget :focus-visible {
289
+ outline: 3px solid var(--_accent);
290
+ outline-offset: 2px;
291
+ }
292
+ .semics-waiver-widget [hidden] {
293
+ display: none !important;
294
+ }
295
+ </style>