@escape-game-over/atlas 0.1.65 → 0.1.67

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/docs/checks.md CHANGED
@@ -89,6 +89,13 @@ augmentation is real. Both examples carry one:
89
89
  two files in it, and [`b2b`](../examples/b2b/type-tests/wiring.ts) against one
90
90
  with none, where the union is empty and *every* path is rejected.
91
91
 
92
+ Declared analytics events are the other augmentation, and the opposite case:
93
+ nothing is inferred, so a declaration means the same thing here as in a project.
94
+ `type-tests/declared-events.ts` writes one by the package's own name, as a
95
+ project does, and asserts that an undeclared name, missing required data and
96
+ data on an event declared without any all fail. The declaration holds for the
97
+ whole program, so the runtime tests call the names it declares.
98
+
92
99
  ## Runtime tests
93
100
 
94
101
  ```bash
@@ -117,7 +124,7 @@ dependencies at all. Vitest type-checks them itself when it runs them.
117
124
  | `breadcrumbs.test.ts` | the trail, a dropped ancestor, `orphanSegments`, and how a gap is reported |
118
125
  | `jsonld/*.test.ts` | that a `</script>` in any value cannot close the block, each node's shape and `@id`, and how a price table becomes offers |
119
126
  | `analytics.test.ts` | Umami's attributes, its three-state booleans, the `domains` list that would record nothing, and that `consentRequired` answers exactly when a tag was emitted |
120
- | `google-analytics.test.ts` | that consent is denied first and precedes every tag, one loader for many ids, dead `UA-` properties, and the preconnect — once, ahead of the block that writes the loader's URL, never `crossorigin` |
127
+ | `google-analytics.test.ts` | that consent is denied first and precedes every tag, one loader for many ids, dead `UA-` properties, the preconnect — once, ahead of the block that writes the loader's URL, never `crossorigin` — and where `googleEvent` sends, per setting |
121
128
  | `contact.test.ts` | the E.164 a `tel:` needs — trunk zero dropped, spacing stripped — and the displayed form kept |
122
129
  | `hours.test.ts` | collapsing a week into runs, week start changing the answer, and every impossible week that throws |
123
130
  | `money.test.ts` | a bare count widened to a band, the span of a table, and the gaps and overlaps that throw |
@@ -535,6 +535,88 @@ move before this pauses them. Left in, everything still works. `muted` is also
535
535
  set on attach, since no browser will start an unmuted video nobody pressed play
536
536
  on.
537
537
 
538
+ ## Events
539
+
540
+ One function per vendor, all dropped silently where the vendor is not
541
+ configured, not loaded or not permitted — an analytics call must never be the
542
+ reason a page misbehaves.
543
+
544
+ | Function | Names | Consent |
545
+ | -------------- | ------------------------------ | ------------------------------------------ |
546
+ | `googleEvent` | GA4's recommended, or declared | Consent Mode, which Google already applies |
547
+ | `metaEvent` | Meta's standard, or declared | the pixel loads only once granted |
548
+ | `tiktokEvent` | TikTok's standard, or declared | the pixel loads only once granted |
549
+ | `snapEvent` | Snap's standard only | checked on every call |
550
+ | `axonEvent` | Axon's standard only | checked on every call |
551
+ | `umamiEvent` | declared only | none needed: Umami sets no cookie |
552
+ | `dripEvent` | declared only | checked on every call |
553
+ | `clarityEvent` | declared only | none needed: cookieless until granted |
554
+
555
+ **A project declares its own events once**, for every site it builds, by
556
+ merging into the interface each function reads — the arrangement
557
+ `PublicFileRegistry` uses. Each name maps to the data it carries, `undefined` for
558
+ none:
559
+
560
+ ```ts
561
+ // src/events.ts — any file the project's tsconfig includes
562
+ export {};
563
+
564
+ declare module "@escape-game-over/atlas/client" {
565
+ interface GoogleCustomEvents {
566
+ form_submission_contact: undefined;
567
+ form_submission_quote: { readonly room: string };
568
+ }
569
+ interface UmamiEvents {
570
+ "franchise-enquiry": { readonly country: string };
571
+ }
572
+ }
573
+ ```
574
+
575
+ ```ts
576
+ googleEvent("form_submission_contact");
577
+ googleEvent("form_submission_quote", { room: "Tomb" });
578
+ googleEvent("form_submissions_contact"); // does not compile
579
+ ```
580
+
581
+ A name nobody declared does not compile, so a typo cannot quietly become a
582
+ second event in a report — which is why this is not a `string` with the standard
583
+ names offered for autocomplete. Data declared with a required field is required
584
+ at every call. The interfaces are `GoogleCustomEvents`, `MetaCustomEvents`,
585
+ `TikTokCustomEvents`, `UmamiEvents`, `DripEvents` and `ClarityEvents`; Clarity
586
+ carries a name only, so its entries are all `undefined`. Snap and Axon take no
587
+ declarations because they accept no names but their own.
588
+
589
+ **The file must be a module** — the `export {}` above. Without an import or an
590
+ export, `declare module` *replaces* the package's types instead of adding to
591
+ them, and every import from it breaks.
592
+
593
+ **Declared data is sent as written, so it names what the visitor chose, never
594
+ who they are.** Google's terms forbid sending it anything that identifies a
595
+ person — a name, an email, a phone number, a free-text message — and its
596
+ [guidance](https://support.google.com/analytics/answer/6366371) names event
597
+ parameters as the usual leak. The standard parameter types have no field for
598
+ any of those; a declaration is where one would have to be added, deliberately.
599
+
600
+ ### `googleEvent`: where it goes
601
+
602
+ The deployment's `google` settings decide, not the caller. The head script
603
+ defines a global — `GOOGLE_EVENT_GLOBAL`, beside `CONSENT_UPDATE_GLOBAL` and for
604
+ the same reason — that sends:
605
+
606
+ - with `tagIds`: `gtag("event", name, data)`, which `gtag.js` reads;
607
+ - with `containerIds`: `dataLayer.push({ event: name, ...data })`, which is what
608
+ a Tag Manager *Custom Event* trigger listens for — the `dataLayer.push` a
609
+ marketing agency's brief asks for;
610
+ - with both, both. A container that also runs a GA4 tag on the same event then
611
+ counts it twice; that is set up in Tag Manager, where lib cannot see it.
612
+
613
+ The ecommerce events — `purchase`, `add_to_cart`, `view_item` and the rest — go
614
+ to Tag Manager under an `ecommerce` key, after a `{ ecommerce: null }` that
615
+ clears the previous one, which is the shape its GA4 tags read items and revenue
616
+ from. `purchase` does not compile without `value`, `currency` and
617
+ `transaction_id`: without them GA records a sale of nothing, and a reload
618
+ records it again.
619
+
538
620
  ## Testing
539
621
 
540
622
  None of these tests has a document, and none needs one: every module under test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -14,8 +14,13 @@ type ConsentGlobal = {
14
14
  ) => void;
15
15
  };
16
16
 
17
+ // `googleEvent`'s way in, under the same arrangement.
18
+ type EventGlobal = {
19
+ [Name in typeof import("./google.ts").GOOGLE_EVENT_GLOBAL]?: import("./google.ts").GoogleEventSink;
20
+ };
21
+
17
22
  // biome-ignore lint/correctness/noUnusedVariables: merges into the global `Window`.
18
- interface Window extends ConsentGlobal {
23
+ interface Window extends ConsentGlobal, EventGlobal {
19
24
  dataLayer: unknown[];
20
25
  gtag: (...args: unknown[]) => void;
21
26
  }
@@ -86,6 +91,24 @@ interface Window extends ConsentGlobal {
86
91
 
87
92
  defineGtag();
88
93
 
94
+ // An event, wherever this deployment's settings send one. Tag Manager
95
+ // reads a pushed object rather than a `gtag` call, and clears the previous
96
+ // `ecommerce` only when told to, as Google documents.
97
+ window.__googleEvent = (name, params, ecommerce) => {
98
+ if (config.tagIds.length > 0) {
99
+ if (params === undefined) window.gtag("event", name);
100
+ else window.gtag("event", name, params);
101
+ }
102
+ if (config.containerIds.length > 0) {
103
+ if (ecommerce) {
104
+ window.dataLayer.push({ ecommerce: null });
105
+ window.dataLayer.push({ event: name, ecommerce: params });
106
+ } else {
107
+ window.dataLayer.push({ event: name, ...params });
108
+ }
109
+ }
110
+ };
111
+
89
112
  // Ahead of everything, which is the entire point of emitting this here.
90
113
  for (const given of config.defaults) {
91
114
  window.gtag("consent", "default", given);
@@ -217,6 +217,28 @@ const CONSENT_KEYS = {
217
217
  */
218
218
  export const CONSENT_UPDATE_GLOBAL = "__consent";
219
219
 
220
+ /**
221
+ * The global `googleEvent` calls to record an event:
222
+ * `__googleEvent("generate_lead", { value: 40 }, true)`.
223
+ *
224
+ * A global for the reason `CONSENT_UPDATE_GLOBAL` is one, and emitted here
225
+ * because only this side knows where an event goes: to `gtag.js` when there
226
+ * are tag ids, onto `dataLayer` as a Tag Manager event when there are
227
+ * containers. A client script deciding that would be a second copy of the
228
+ * settings.
229
+ */
230
+ export const GOOGLE_EVENT_GLOBAL = "__googleEvent";
231
+
232
+ /**
233
+ * What `GOOGLE_EVENT_GLOBAL` holds. `ecommerce` says the parameters go under
234
+ * Tag Manager's `ecommerce` key, where its GA4 tags read items and revenue.
235
+ */
236
+ export type GoogleEventSink = (
237
+ name: string,
238
+ params: object | undefined,
239
+ ecommerce: boolean
240
+ ) => void;
241
+
220
242
  /** The signal keys of a set of defaults — not `region`, not `waitForUpdate`. */
221
243
  const SIGNAL_KEYS = [
222
244
  "adStorage",
@@ -1,3 +1,21 @@
1
+ import type { DeclaredEvent } from "./event-data.ts";
2
+
3
+ /**
4
+ * The project's Clarity events. Clarity carries a name and nothing else, so
5
+ * each is declared `undefined`. Nothing compiles until a project declares its
6
+ * own, once:
7
+ *
8
+ * ```ts
9
+ * declare module "@escape-game-over/atlas/client" {
10
+ * interface ClarityEvents {
11
+ * "form sent": undefined;
12
+ * }
13
+ * }
14
+ * ```
15
+ */
16
+ // biome-ignore lint/suspicious/noEmptyInterface: filled by each project's declaration.
17
+ export interface ClarityEvents {}
18
+
1
19
  /** What `clarity.ts` defines once the page has loaded. */
2
20
  interface ClarityWindow {
3
21
  clarity?: (...args: unknown[]) => void;
@@ -10,6 +28,6 @@ interface ClarityWindow {
10
28
  * Sent with or without consent: Clarity records either way, and without it
11
29
  * sets no cookie. Dropped before Clarity loads, or where it is not configured.
12
30
  */
13
- export function clarityEvent(name: string): void {
31
+ export function clarityEvent(name: DeclaredEvent<ClarityEvents>): void {
14
32
  (window as unknown as ClarityWindow).clarity?.("event", name);
15
33
  }
@@ -26,6 +26,7 @@ export {
26
26
  } from "./element.ts";
27
27
  export * from "./filters.ts";
28
28
  export * from "./filters-view.ts";
29
+ export * from "./google-event.ts";
29
30
  export * from "./meta-event.ts";
30
31
  export { data, ref, refs } from "./ref.ts";
31
32
  export * from "./snap-event.ts";
@@ -1,4 +1,21 @@
1
1
  import { readConsent } from "./consent.ts";
2
+ import type { DeclaredEvent, EventDataArgs } from "./event-data.ts";
3
+
4
+ /**
5
+ * The project's Drip events — the actions its workflows start on — each with
6
+ * the properties it carries, `undefined` for none. Drip's events are named by
7
+ * the site, so nothing compiles until a project declares its own, once:
8
+ *
9
+ * ```ts
10
+ * declare module "@escape-game-over/atlas/client" {
11
+ * interface DripEvents {
12
+ * "Bought a voucher": { readonly value: number };
13
+ * }
14
+ * }
15
+ * ```
16
+ */
17
+ // biome-ignore lint/suspicious/noEmptyInterface: filled by each project's declaration.
18
+ export interface DripEvents {}
2
19
 
3
20
  /** What `drip.ts` defines once marketing is granted. */
4
21
  interface DripWindow {
@@ -7,15 +24,18 @@ interface DripWindow {
7
24
 
8
25
  /**
9
26
  * Records an event with Drip, to start a workflow — a lead sent, a voucher
10
- * bought. Drip's events are named by the site, so any name goes.
27
+ * bought.
11
28
  *
12
29
  * Dropped unless Drip loaded and marketing is still granted: Drip has no
13
30
  * consent call of its own, so the check is here. An analytics call must never
14
31
  * be the reason a page misbehaves, so a dropped event is silent.
15
32
  */
16
- export function dripEvent(
17
- action: string,
18
- properties?: Readonly<Record<string, string | number | boolean>>
33
+ export function dripEvent<E extends DeclaredEvent<DripEvents>>(
34
+ action: E,
35
+ // Drip takes flat values; a nested object in a declaration fails here.
36
+ ...[properties]: EventDataArgs<
37
+ DripEvents[E] & Readonly<Record<string, string | number | boolean>>
38
+ >
19
39
  ): void {
20
40
  if (readConsent()?.marketing !== "granted") return;
21
41
  const queue = (window as unknown as DripWindow)._dcq;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The arguments an event's declared data asks for: none when it is
3
+ * `undefined`, optional when every field is, required otherwise.
4
+ *
5
+ * Shared by every vendor whose events a project declares for itself, so
6
+ * `{ form_sent: undefined }` means the same thing in each.
7
+ */
8
+ export type EventDataArgs<Data> = [Data] extends [undefined]
9
+ ? []
10
+ : Partial<Data> extends Data
11
+ ? [data?: Data]
12
+ : [data: Data];
13
+
14
+ /** The event names a project declared, as the strings they are sent as. */
15
+ export type DeclaredEvent<Declared> = Extract<keyof Declared, string>;
@@ -0,0 +1,119 @@
1
+ import {
2
+ GOOGLE_EVENT_GLOBAL,
3
+ type GoogleEventSink,
4
+ } from "../analytics/google.ts";
5
+ import type { CurrencyCode } from "../money.ts";
6
+ import type { DeclaredEvent, EventDataArgs } from "./event-data.ts";
7
+
8
+ /** GA4's recommended events that carry items and revenue. */
9
+ const ECOMMERCE_EVENTS = [
10
+ "add_payment_info",
11
+ "add_to_cart",
12
+ "add_to_wishlist",
13
+ "begin_checkout",
14
+ "purchase",
15
+ "select_item",
16
+ "select_promotion",
17
+ "view_cart",
18
+ "view_item",
19
+ "view_item_list",
20
+ "view_promotion",
21
+ ] as const;
22
+
23
+ /** GA4's recommended events that a website sends, which its reports know by name. */
24
+ export type GoogleRecommendedEvent =
25
+ | (typeof ECOMMERCE_EVENTS)[number]
26
+ | "generate_lead"
27
+ | "login"
28
+ | "search"
29
+ | "select_content"
30
+ | "share"
31
+ | "sign_up";
32
+
33
+ /**
34
+ * The project's own events — what its Tag Manager triggers listen for — each
35
+ * with the data it carries, `undefined` for none. Empty here; a project fills
36
+ * it once, and a name it did not declare does not compile:
37
+ *
38
+ * ```ts
39
+ * declare module "@escape-game-over/atlas/client" {
40
+ * interface GoogleCustomEvents {
41
+ * form_submission_contact: undefined;
42
+ * form_submission_quote: { readonly room: string };
43
+ * }
44
+ * }
45
+ * ```
46
+ */
47
+ // biome-ignore lint/suspicious/noEmptyInterface: filled by each project's declaration.
48
+ export interface GoogleCustomEvents {}
49
+
50
+ /** A recommended event, or one the project declared. */
51
+ export type GoogleEventName =
52
+ | GoogleRecommendedEvent
53
+ | DeclaredEvent<GoogleCustomEvents>;
54
+
55
+ /** One item of an ecommerce event. Google needs its id or its name. */
56
+ export type GoogleItem = (
57
+ | { readonly item_id: string; readonly item_name?: string }
58
+ | { readonly item_id?: string; readonly item_name: string }
59
+ ) & {
60
+ readonly item_category?: string;
61
+ readonly item_variant?: string;
62
+ readonly price?: number;
63
+ readonly quantity?: number;
64
+ readonly coupon?: string;
65
+ };
66
+
67
+ /** GA4's standard parameters. What the visitor chose, never who they are. */
68
+ export interface GoogleEventData {
69
+ readonly value?: number;
70
+ readonly currency?: CurrencyCode;
71
+ readonly transaction_id?: string;
72
+ readonly coupon?: string;
73
+ readonly items?: readonly GoogleItem[];
74
+ readonly search_term?: string;
75
+ readonly method?: string;
76
+ readonly content_type?: string;
77
+ readonly content_id?: string;
78
+ }
79
+
80
+ /**
81
+ * A recommended event takes GA4's parameters; `purchase` without its three
82
+ * reports a sale of nothing, possibly twice. A declared one takes what the
83
+ * project declared.
84
+ */
85
+ type GoogleEventArgs<E extends GoogleEventName> =
86
+ E extends GoogleRecommendedEvent
87
+ ? E extends "purchase"
88
+ ? [
89
+ data: GoogleEventData & {
90
+ value: number;
91
+ currency: CurrencyCode;
92
+ transaction_id: string;
93
+ },
94
+ ]
95
+ : [data?: GoogleEventData]
96
+ : EventDataArgs<GoogleCustomEvents[E & keyof GoogleCustomEvents]>;
97
+
98
+ /**
99
+ * Records an event with Google — a lead sent, a booking made — through
100
+ * `gtag.js`, Tag Manager, or both, as the deployment's `google` settings say.
101
+ *
102
+ * Sent whatever the visitor answered: Consent Mode is how Google is told, and
103
+ * it already holds back what was not granted. Dropped where Google is not
104
+ * configured, since an analytics call must never be the reason a page
105
+ * misbehaves.
106
+ */
107
+ export function googleEvent<E extends GoogleEventName>(
108
+ event: E,
109
+ ...[data]: GoogleEventArgs<E>
110
+ ): void {
111
+ const send = (
112
+ window as unknown as Record<string, GoogleEventSink | undefined>
113
+ )[GOOGLE_EVENT_GLOBAL];
114
+ send?.(
115
+ event,
116
+ data,
117
+ (ECOMMERCE_EVENTS as readonly string[]).includes(event)
118
+ );
119
+ }
@@ -1,24 +1,46 @@
1
1
  import type { CurrencyCode } from "../money.ts";
2
+ import type { DeclaredEvent, EventDataArgs } from "./event-data.ts";
2
3
 
3
4
  /** Meta's standard events, which its ad optimisation understands by name. */
4
- export type MetaStandardEvent =
5
- | "AddPaymentInfo"
6
- | "AddToCart"
7
- | "AddToWishlist"
8
- | "CompleteRegistration"
9
- | "Contact"
10
- | "CustomizeProduct"
11
- | "Donate"
12
- | "FindLocation"
13
- | "InitiateCheckout"
14
- | "Lead"
15
- | "Purchase"
16
- | "Schedule"
17
- | "Search"
18
- | "StartTrial"
19
- | "SubmitApplication"
20
- | "Subscribe"
21
- | "ViewContent";
5
+ const META_STANDARD_EVENTS = [
6
+ "AddPaymentInfo",
7
+ "AddToCart",
8
+ "AddToWishlist",
9
+ "CompleteRegistration",
10
+ "Contact",
11
+ "CustomizeProduct",
12
+ "Donate",
13
+ "FindLocation",
14
+ "InitiateCheckout",
15
+ "Lead",
16
+ "Purchase",
17
+ "Schedule",
18
+ "Search",
19
+ "StartTrial",
20
+ "SubmitApplication",
21
+ "Subscribe",
22
+ "ViewContent",
23
+ ] as const;
24
+
25
+ export type MetaStandardEvent = (typeof META_STANDARD_EVENTS)[number];
26
+
27
+ /**
28
+ * The project's own events, sent as Meta's custom events, each with the data
29
+ * it carries, `undefined` for none. Empty here; a project fills it once:
30
+ *
31
+ * ```ts
32
+ * declare module "@escape-game-over/atlas/client" {
33
+ * interface MetaCustomEvents {
34
+ * VoucherViewed: undefined;
35
+ * }
36
+ * }
37
+ * ```
38
+ */
39
+ // biome-ignore lint/suspicious/noEmptyInterface: filled by each project's declaration.
40
+ export interface MetaCustomEvents {}
41
+
42
+ /** A standard event, or one the project declared. */
43
+ export type MetaEventName = MetaStandardEvent | DeclaredEvent<MetaCustomEvents>;
22
44
 
23
45
  /** Meta's standard parameters. What the visitor chose, never who they are. */
24
46
  export interface MetaEventData {
@@ -33,10 +55,15 @@ export interface MetaEventData {
33
55
  readonly status?: boolean;
34
56
  }
35
57
 
36
- /** `Purchase` is the one event Meta rejects without a value and currency. */
37
- type MetaEventArgs<E extends MetaStandardEvent> = E extends "Purchase"
38
- ? [data: MetaEventData & { value: number; currency: CurrencyCode }]
39
- : [data?: MetaEventData];
58
+ /**
59
+ * A standard event takes Meta's parameters; `Purchase` is the one it rejects
60
+ * without a value and currency. A declared one takes what the project declared.
61
+ */
62
+ type MetaEventArgs<E extends MetaEventName> = E extends MetaStandardEvent
63
+ ? E extends "Purchase"
64
+ ? [data: MetaEventData & { value: number; currency: CurrencyCode }]
65
+ : [data?: MetaEventData]
66
+ : EventDataArgs<MetaCustomEvents[E & keyof MetaCustomEvents]>;
40
67
 
41
68
  /** What `meta-pixel.ts` defines once marketing is granted. */
42
69
  interface MetaWindow {
@@ -44,18 +71,22 @@ interface MetaWindow {
44
71
  }
45
72
 
46
73
  /**
47
- * Records a standard event with Meta's pixel — a lead sent, a booking made.
74
+ * Records an event with Meta's pixel — a lead sent, a booking made. A declared
75
+ * event is sent as Meta's custom event, which is how Meta tells them apart.
48
76
  *
49
77
  * `fbq` exists only once the visitor granted marketing and the pixel loaded.
50
78
  * Before that, without consent, or where Meta is not configured, the event is
51
79
  * dropped: no consent means nothing to send, and an analytics call must never
52
80
  * be the reason a page misbehaves.
53
81
  */
54
- export function metaEvent<E extends MetaStandardEvent>(
82
+ export function metaEvent<E extends MetaEventName>(
55
83
  event: E,
56
84
  ...[data]: MetaEventArgs<E>
57
85
  ): void {
58
86
  const fbq = (window as unknown as MetaWindow).fbq;
59
- if (data === undefined) fbq?.("track", event);
60
- else fbq?.("track", event, data);
87
+ const method = (META_STANDARD_EVENTS as readonly string[]).includes(event)
88
+ ? "track"
89
+ : "trackCustom";
90
+ if (data === undefined) fbq?.(method, event);
91
+ else fbq?.(method, event, data);
61
92
  }
@@ -1,4 +1,5 @@
1
1
  import type { CurrencyCode } from "../money.ts";
2
+ import type { DeclaredEvent, EventDataArgs } from "./event-data.ts";
2
3
 
3
4
  /** TikTok's standard events, by their current names (May 2025 onwards). */
4
5
  export type TikTokStandardEvent =
@@ -18,6 +19,26 @@ export type TikTokStandardEvent =
18
19
  | "Subscribe"
19
20
  | "ViewContent";
20
21
 
22
+ /**
23
+ * The project's own events, each with the data it carries, `undefined` for
24
+ * none. Empty here; a project fills it once:
25
+ *
26
+ * ```ts
27
+ * declare module "@escape-game-over/atlas/client" {
28
+ * interface TikTokCustomEvents {
29
+ * VoucherViewed: undefined;
30
+ * }
31
+ * }
32
+ * ```
33
+ */
34
+ // biome-ignore lint/suspicious/noEmptyInterface: filled by each project's declaration.
35
+ export interface TikTokCustomEvents {}
36
+
37
+ /** A standard event, or one the project declared. */
38
+ export type TikTokEventName =
39
+ | TikTokStandardEvent
40
+ | DeclaredEvent<TikTokCustomEvents>;
41
+
21
42
  /** TikTok's standard parameters. What the visitor chose, never who they are. */
22
43
  export interface TikTokEventData {
23
44
  readonly value?: number;
@@ -31,10 +52,15 @@ export interface TikTokEventData {
31
52
  readonly description?: string;
32
53
  }
33
54
 
34
- /** `Purchase` needs a value and currency to be worth anything to TikTok. */
35
- type TikTokEventArgs<E extends TikTokStandardEvent> = E extends "Purchase"
36
- ? [data: TikTokEventData & { value: number; currency: CurrencyCode }]
37
- : [data?: TikTokEventData];
55
+ /**
56
+ * A standard event takes TikTok's parameters; `Purchase` needs a value and
57
+ * currency to be worth anything. A declared one takes what the project declared.
58
+ */
59
+ type TikTokEventArgs<E extends TikTokEventName> = E extends TikTokStandardEvent
60
+ ? E extends "Purchase"
61
+ ? [data: TikTokEventData & { value: number; currency: CurrencyCode }]
62
+ : [data?: TikTokEventData]
63
+ : EventDataArgs<TikTokCustomEvents[E & keyof TikTokCustomEvents]>;
38
64
 
39
65
  /** What `tiktok-pixel.ts` defines once marketing is granted. */
40
66
  interface TikTokWindow {
@@ -42,14 +68,15 @@ interface TikTokWindow {
42
68
  }
43
69
 
44
70
  /**
45
- * Records a standard event with TikTok's pixel — a lead sent, a booking made.
71
+ * Records an event with TikTok's pixel — a lead sent, a booking made. Declared
72
+ * events go through the same call.
46
73
  *
47
74
  * `ttq` exists only once the visitor granted marketing and the pixel loaded.
48
75
  * Before that, without consent, or where TikTok is not configured, the event is
49
76
  * dropped: no consent means nothing to send, and an analytics call must never
50
77
  * be the reason a page misbehaves.
51
78
  */
52
- export function tiktokEvent<E extends TikTokStandardEvent>(
79
+ export function tiktokEvent<E extends TikTokEventName>(
53
80
  event: E,
54
81
  ...[data]: TikTokEventArgs<E>
55
82
  ): void {
@@ -1,3 +1,21 @@
1
+ import type { DeclaredEvent, EventDataArgs } from "./event-data.ts";
2
+
3
+ /**
4
+ * The project's Umami events, each with the data it carries, `undefined` for
5
+ * none. Umami has no standard events, so nothing compiles until a project
6
+ * declares its own, once:
7
+ *
8
+ * ```ts
9
+ * declare module "@escape-game-over/atlas/client" {
10
+ * interface UmamiEvents {
11
+ * "franchise-enquiry": { readonly country: string };
12
+ * }
13
+ * }
14
+ * ```
15
+ */
16
+ // biome-ignore lint/suspicious/noEmptyInterface: filled by each project's declaration.
17
+ export interface UmamiEvents {}
18
+
1
19
  /**
2
20
  * Records a named event with Umami — a form sent, a deck downloaded — from a
3
21
  * client script.
@@ -11,9 +29,10 @@
11
29
  * never who they are. For an event on a plain click, Umami's
12
30
  * `data-umami-event` attribute needs no script at all.
13
31
  */
14
- export function umamiEvent(
15
- name: string,
16
- data?: Readonly<Record<string, string>>
32
+ export function umamiEvent<E extends DeclaredEvent<UmamiEvents>>(
33
+ name: E,
34
+ // Flat string values; anything else in a declaration fails here.
35
+ ...[data]: EventDataArgs<UmamiEvents[E] & Readonly<Record<string, string>>>
17
36
  ): void {
18
37
  const umami = (
19
38
  window as unknown as {