@semics-tech/emdash-events-suite 1.0.0-rc.4 → 1.0.0-rc.5

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.
@@ -1,3 +1,4 @@
1
+ import { type PageOptions } from "@semics-tech/emdash-events/integration";
1
2
  import type { PluginDescriptor } from "emdash";
2
3
  /**
3
4
  * A core the suite links: a sandboxed descriptor to load from npm (the product package's default
@@ -12,6 +13,8 @@ export interface EventsSuiteOptions {
12
13
  events?: SuiteCore;
13
14
  /** Default: the Free E-sign core of `@semics-tech/emdash-esign`. */
14
15
  esign?: SuiteCore;
16
+ /** How `/booking` and `/_events/waiver` look on this site: theme, stylesheet, brand, event page path. */
17
+ page?: PageOptions;
15
18
  }
16
19
  /** The slice of Astro's integration API the injected pages need (no `astro` dependency). */
17
20
  interface SetupArgs {
@@ -58,7 +61,7 @@ export declare const PAIR_ROUTE = "/_events/pair";
58
61
  export declare function suiteIntegration(ids: {
59
62
  eventsPluginId: string;
60
63
  esignPluginId: string;
61
- }): SuiteAstroIntegration;
64
+ }, page?: PageOptions): SuiteAstroIntegration;
62
65
  /**
63
66
  * Events and E-sign on one site (SX-FR-01). Every part is told the id of each core it calls, taken
64
67
  * from the descriptor or the registry id given here, never assumed:
package/dist/ts/index.js CHANGED
@@ -28,10 +28,11 @@ const RECONCILE_MODULE = "@semics-tech/emdash-events-suite/astro/lib/reconcile";
28
28
  * Injects `/_events/waiver` (the booking and signing steps of `<BookingWithWaiver>`) and gives it,
29
29
  * through a virtual module, the id of each core. `eventsSuite()` adds it for you.
30
30
  */
31
- export function suiteIntegration(ids) {
31
+ export function suiteIntegration(ids, page = {}) {
32
32
  const config = [
33
33
  `export const eventsPluginId = ${JSON.stringify(ids.eventsPluginId)};`,
34
34
  `export const esignPluginId = ${JSON.stringify(ids.esignPluginId)};`,
35
+ `export const page = ${JSON.stringify(page)};`,
35
36
  ].join("\n");
36
37
  return {
37
38
  name: "semics-events-suite",
@@ -85,9 +86,14 @@ export function eventsSuite(options = {}) {
85
86
  sandboxed: [events.descriptor, esign.descriptor].filter((d) => d !== undefined),
86
87
  plugins: [eventsUi(), esignUi()],
87
88
  integrations: [
88
- eventsIntegration({ pluginId: events.id, esignPluginId: esign.id, reconcile: RECONCILE_MODULE }),
89
+ eventsIntegration({
90
+ pluginId: events.id,
91
+ esignPluginId: esign.id,
92
+ reconcile: RECONCILE_MODULE,
93
+ ...(options.page ? { page: options.page } : {}),
94
+ }),
89
95
  esignIntegration({ pluginId: esign.id }),
90
- suiteIntegration({ eventsPluginId: events.id, esignPluginId: esign.id }),
96
+ suiteIntegration({ eventsPluginId: events.id, esignPluginId: esign.id }, options.page),
91
97
  ],
92
98
  pluginIds: { events: events.id, esign: esign.id },
93
99
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semics-tech/emdash-events-suite",
3
- "version": "1.0.0-rc.4",
3
+ "version": "1.0.0-rc.5",
4
4
  "type": "module",
5
5
  "description": "Links semics Events and E-sign on one EmDash site: one config call and a booking flow with inline waiver signing",
6
6
  "license": "MIT",
@@ -22,8 +22,8 @@
22
22
  "src/astro"
23
23
  ],
24
24
  "dependencies": {
25
- "@semics-tech/emdash-esign": "1.0.0-rc.4",
26
- "@semics-tech/emdash-events": "1.0.0-rc.4"
25
+ "@semics-tech/emdash-esign": "1.0.0-rc.5",
26
+ "@semics-tech/emdash-events": "1.0.0-rc.5"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "emdash": ">=1.2.0 <2"
@@ -94,12 +94,13 @@ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
94
94
  !onSale ? (
95
95
  <p data-testid="booking-unavailable">Booking is not available for this event right now.</p>
96
96
  ) : (
97
- <form method="post" action={WAIVER_PATH}>
97
+ <form method="post" action={WAIVER_PATH} data-keep-values>
98
98
  <p class="semics-form-alert" role="alert" data-testid="waiver-alert" hidden={!alert}>{alert}</p>
99
99
  <input type="hidden" name="intent" value="book" />
100
100
  <input type="hidden" name="eventId" value={eventId} />
101
101
  <input type="hidden" name="returnTo" value={returnTo} />
102
102
  <input type="hidden" name="idempotencyKey" value={newIdempotencyKey()} />
103
+ {data?.waiverEach && <input type="hidden" name="waiverEach" value="1" />}
103
104
 
104
105
  {picker && (
105
106
  <div class="semics-field semics-picker-field">
@@ -301,6 +302,8 @@ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
301
302
  </section>
302
303
 
303
304
  <script>
305
+ // In this order: the kept values go back first, then the picker shows the date the select holds.
306
+ import "./lib/keep-form";
304
307
  import "@semics-tech/emdash-events/client/picker";
305
308
  </script>
306
309
 
@@ -2,13 +2,15 @@
2
2
  // Injected at `/_events/waiver` by the suite (SX-FR-02, SX-FR-03): the booking post of
3
3
  // <BookingWithWaiver> and the inline waiver ceremony, one signer after another, on one page.
4
4
  // Signing tokens live only in an HttpOnly cookie scoped to this path and in POST bodies to E-sign;
5
- // receipts are forwarded to Events in-process. The page renders E-sign's <SigningCeremony> and
6
- // nothing else inline, so it sends E-sign's `ceremonyCsp()` (hashes of the ceremony's own script,
7
- // pad and stylesheet), `Referrer-Policy: no-referrer` and `no-store`.
8
- import { esignPluginId, eventsPluginId } from "virtual:semics-events-suite/config";
5
+ // receipts are forwarded to Events in-process. The page renders E-sign's <SigningCeremony> and, inline,
6
+ // only Events' page stylesheet, so it sends E-sign's `ceremonyCsp()` (hashes of the ceremony's own script,
7
+ // pad and stylesheet, and of the page's), `Referrer-Policy: no-referrer` and `no-store`.
8
+ import { esignPluginId, eventsPluginId, page } from "virtual:semics-events-suite/config";
9
9
  import SigningCeremony from "@semics-tech/emdash-esign/astro/SigningCeremony.astro";
10
10
  import { ceremonyCsp } from "@semics-tech/emdash-esign/astro/lib/csp";
11
+ import PageBrand from "@semics-tech/emdash-events/astro/PageBrand.astro";
11
12
  import { crossSiteResponse } from "@semics-tech/emdash-events/astro/lib/checkout-form";
13
+ import { pageCss } from "@semics-tech/emdash-events/astro/lib/page";
12
14
 
13
15
  import { handleWaiverRequest, leavesSite } from "./lib/flow";
14
16
  import { WAIVER_MESSAGES, WAIVER_PATH } from "./lib/form";
@@ -18,7 +20,7 @@ export const prerender = false;
18
20
 
19
21
  const outcome = await handleWaiverRequest(
20
22
  { locals: Astro.locals, url: Astro.url, request: Astro.request, cookie: (name) => Astro.cookies.get(name)?.value },
21
- { eventsPluginId, esignPluginId },
23
+ { eventsPluginId, esignPluginId, ...(page.eventPath ? { eventPath: page.eventPath } : {}) },
22
24
  );
23
25
  if (outcome.type === "forbidden") return crossSiteResponse();
24
26
  const cookies = outcome.type === "redirect" || outcome.type === "render" ? outcome.cookies : [];
@@ -29,7 +31,9 @@ const payment = outcome.type === "redirect" ? outcome.location : undefined;
29
31
 
30
32
  Astro.response.headers.set("Referrer-Policy", "no-referrer");
31
33
  Astro.response.headers.set("Cache-Control", "private, no-store");
32
- Astro.response.headers.set("Content-Security-Policy", await ceremonyCsp());
34
+ const css = pageCss(page);
35
+ const stylesheet = page.stylesheet ? new URL(page.stylesheet, Astro.url).href : undefined;
36
+ Astro.response.headers.set("Content-Security-Policy", await ceremonyCsp({ styles: [css], stylesheet }));
33
37
 
34
38
  const render = outcome.type === "render" ? outcome : undefined;
35
39
  const ref = outcome.type === "render" || outcome.type === "error" ? (outcome.ref ?? "") : "";
@@ -58,15 +62,18 @@ const signer = render?.view.kind === "ready" ? render.view.session.signer : unde
58
62
  <meta name="referrer" content="no-referrer" />
59
63
  <title>{payment ? "Continue to payment" : "Sign the waiver"}</title>
60
64
  {payment && <meta http-equiv="refresh" content={`0;url=${payment}`} data-testid="waiver-pay-refresh" />}
65
+ <style is:inline set:html={css}></style>
66
+ {page.stylesheet && <link rel="stylesheet" href={page.stylesheet} />}
61
67
  </head>
62
- <body>
68
+ <body class="semics-page">
69
+ <PageBrand brand={page.brand} />
63
70
  {payment ? (
64
- <main class="semics-esign semics-sign" data-testid="waiver-page" data-step="pay">
71
+ <main class="semics-esign semics-sign semics-page-main" data-testid="waiver-page" data-step="pay">
65
72
  <h1>Continue to payment</h1>
66
73
  <p><a href={payment} data-testid="waiver-pay-link">Go to the payment page</a></p>
67
74
  </main>
68
75
  ) : (
69
- <main class="semics-esign semics-sign" data-testid="waiver-page" data-step={render ? "sign" : "error"}>
76
+ <main class="semics-esign semics-sign semics-page-main" data-testid="waiver-page" data-step={render ? "sign" : "error"}>
70
77
  <h1>Sign the waiver</h1>
71
78
  {error && <p role="alert" data-testid="waiver-error">{error}</p>}
72
79
  {expired && eventLink && <p><a href={eventLink} data-testid="waiver-book-again">Book again</a></p>}
@@ -26,12 +26,13 @@ import {
26
26
  bookingCookieOptions,
27
27
  isCrossSiteRequest,
28
28
  } from "@semics-tech/emdash-events/astro/lib/checkout-form";
29
- import { eventHref } from "@semics-tech/emdash-events/astro/lib/list";
29
+ import { eventPageHref } from "@semics-tech/emdash-events/astro/lib/page";
30
30
  import { isPayUrl } from "@semics-tech/emdash-events/astro/lib/payment";
31
31
  import { forwardReceipt } from "@semics-tech/emdash-events/astro/lib/receipt";
32
32
  import { newIdempotencyKey } from "@semics-tech/emdash-events/astro/lib/widget";
33
33
 
34
34
  import {
35
+ checkWaiverSeats,
35
36
  readWaiverForm,
36
37
  type SeatPart,
37
38
  safeReturnPath,
@@ -48,6 +49,8 @@ export const FLOW_STEPS = ["book", "sign", "pay", "done"] as const;
48
49
  export interface SuiteIds {
49
50
  eventsPluginId: string;
50
51
  esignPluginId: string;
52
+ /** The site's event page for "Book again" (`page.eventPath`); default `/events/{slug}`. */
53
+ eventPath?: string;
51
54
  }
52
55
 
53
56
  export interface WaiverRequestContext {
@@ -262,6 +265,11 @@ async function book(ctx: WaiverRequestContext, form: FormData, ids: SuiteIds, de
262
265
  const read = readWaiverForm(form);
263
266
  if (!read.ok) return refuse(read.code, read.fields);
264
267
  const order = read.order;
268
+ // An order the waiver rules refuse never reserves: no booking is left behind, and no "cancelled" email sent.
269
+ if (form.get("waiverEach") === "1") {
270
+ const seats = checkWaiverSeats(order);
271
+ if (seats) return refuse(seats.code, seats.fields);
272
+ }
265
273
 
266
274
  const reserved = await post<{ bookingRef?: string; accessToken?: string }>(
267
275
  call,
@@ -447,7 +455,7 @@ async function sign(
447
455
  esignPluginId: ids.esignPluginId,
448
456
  });
449
457
  }
450
- const href = booking.event?.slug ? eventHref({ slug: booking.event.slug }) : undefined;
458
+ const href = eventPageHref(booking.event?.slug, ids.eventPath);
451
459
  return { type: "error", ref, code: "BOOKING_EXPIRED", ...(href ? { href } : {}) };
452
460
  }
453
461
  const withBooking = booking ? { booking } : {};
@@ -120,6 +120,30 @@ export function readWaiverForm(form: FormData): WaiverFormResult {
120
120
  };
121
121
  }
122
122
 
123
+ /** Adults one order can have invited by email (Events Pro's `MAX_EMAIL_SIGNERS`). */
124
+ const MAX_EMAIL_SIGNERS = 10;
125
+ const norm = (value: string) => value.trim().toLowerCase();
126
+
127
+ /**
128
+ * Events Pro's waiver rules for an event signed per attendee (`planWaiverIntents`), asked before anything is
129
+ * reserved: an adult with the buyer's address (or none) must be the buyer, once; other adults are invited by
130
+ * email, up to the limit; the buyer signs for minors. The core stays the authority: it checks again at continue.
131
+ */
132
+ export function checkWaiverSeats(order: WaiverOrder): { code: string; fields: string[] } | null {
133
+ const fields: string[] = [];
134
+ let buyerSeated = false;
135
+ let emailed = 0;
136
+ for (const [i, a] of order.attendees.entries()) {
137
+ if (a.minor) continue;
138
+ if (norm(a.email ?? order.buyer.email) !== norm(order.buyer.email)) emailed++;
139
+ else if (buyerSeated || norm(a.name) !== norm(order.buyer.name))
140
+ fields.push(seatField(order.rows[i] ?? 0, "email"));
141
+ else buyerSeated = true;
142
+ }
143
+ if (fields.length > 0) return { code: "WAIVER_EMAIL_REQUIRED", fields };
144
+ return emailed > MAX_EMAIL_SIGNERS ? { code: "WAIVER_TOO_MANY_EMAILS", fields: [] } : null;
145
+ }
146
+
123
147
  /** Messages for the codes a refused booking comes back with. */
124
148
  export const WAIVER_MESSAGES: Record<string, string> = {
125
149
  BAD_REQUEST: "Please check the highlighted details.",
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Keeps what a person typed when a booking is refused (`<BookingWithWaiver>`). The refusal is a redirect back to
3
+ * the page with codes only: personal details never go in a URL, and a server-rendered copy could end up in a page
4
+ * cache. So the browser keeps them: on submit this tab's `sessionStorage` takes the form's values, and the
5
+ * page the refusal lands on puts them back. Any other visit discards them; so does closing the tab. Without
6
+ * JavaScript (or storage) the form comes back empty with its message, as before. No text; no imports.
7
+ */
8
+
9
+ /** The parts of a form control this needs. */
10
+ export interface KeptField {
11
+ name: string;
12
+ type: string;
13
+ value: string;
14
+ checked?: boolean;
15
+ }
16
+
17
+ export const KEPT_MS = 15 * 60_000;
18
+ export const keptKey = (eventId: string) => `semics-waiver-form:${eventId}`;
19
+
20
+ type Stored = Pick<Storage, "getItem" | "setItem" | "removeItem">;
21
+
22
+ /** What to keep: what the person chose, never hidden fields (the idempotency key, the security check's token). */
23
+ export function collectFields(fields: Iterable<KeptField>): Record<string, string | boolean> {
24
+ const out: Record<string, string | boolean> = {};
25
+ for (const f of fields) {
26
+ if (!f.name || f.type === "hidden" || f.type === "submit" || f.type === "button") continue;
27
+ if (f.type === "checkbox") {
28
+ if (f.checked) out[f.name] = true;
29
+ } else if (f.value) out[f.name] = f.value;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export function applyFields(fields: Iterable<KeptField>, kept: Record<string, string | boolean>): void {
35
+ for (const f of fields) {
36
+ const value = kept[f.name];
37
+ if (value === undefined || f.type === "hidden") continue;
38
+ if (f.type === "checkbox") f.checked = value === true;
39
+ else if (typeof value === "string") f.value = value;
40
+ }
41
+ }
42
+
43
+ export function saveKept(storage: Stored, eventId: string, fields: Iterable<KeptField>, now: number): void {
44
+ try {
45
+ storage.setItem(keptKey(eventId), JSON.stringify({ at: now, values: collectFields(fields) }));
46
+ } catch {
47
+ // Private browsing, a full quota: the form just comes back empty.
48
+ }
49
+ }
50
+
51
+ /** The values to restore: only after a refusal of this event's form, once, and only while fresh. */
52
+ export function takeKept(storage: Stored, eventId: string, search: URLSearchParams, now: number) {
53
+ try {
54
+ const raw = storage.getItem(keptKey(eventId));
55
+ storage.removeItem(keptKey(eventId));
56
+ if (!raw || !search.get("waiver_error") || search.get("waiver_event") !== eventId) return null;
57
+ const { at, values } = JSON.parse(raw) as { at: number; values: Record<string, string | boolean> };
58
+ return now - at <= KEPT_MS ? values : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** Wires every booking form on the page. Run before the picker's script, which shows the date the select holds. */
65
+ export function keepWaiverForms(doc: Document, storage: Stored, url: URL): void {
66
+ for (const form of doc.querySelectorAll<HTMLFormElement>("form[data-keep-values]")) {
67
+ const eventId = (form.elements.namedItem("eventId") as HTMLInputElement | null)?.value ?? "";
68
+ const fields = () => [...form.elements] as unknown as HTMLInputElement[];
69
+ const kept = takeKept(storage, eventId, url.searchParams, Date.now());
70
+ if (kept) applyFields(fields(), kept);
71
+ form.addEventListener("submit", () => saveKept(storage, eventId, fields(), Date.now()));
72
+ }
73
+ }
74
+
75
+ if (typeof document !== "undefined" && typeof sessionStorage !== "undefined") {
76
+ keepWaiverForms(document, sessionStorage, new URL(location.href));
77
+ }
@@ -4,4 +4,6 @@ declare module "virtual:semics-events-suite/config" {
4
4
  export const eventsPluginId: string;
5
5
  /** The E-sign core's plugin id on this site. */
6
6
  export const esignPluginId: string;
7
+ /** How the site dresses the suite's page (`eventsSuite({ page })`); checked by Events' integration. */
8
+ export const page: import("@semics-tech/emdash-events/astro/lib/page").PageOptions;
7
9
  }