@escape-game-over/atlas 0.1.106 → 0.1.107

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.106",
3
+ "version": "0.1.107",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -22,6 +22,7 @@
22
22
  "./astro/consent-banner": "./src/astro/ConsentBanner.astro",
23
23
  "./astro/document": "./src/astro/Document.astro",
24
24
  "./astro/element": "./src/astro/AtlasElement.astro",
25
+ "./astro/escape-navigator": "./src/astro/EscapeNavigator.astro",
25
26
  "./astro/four-escape": "./src/astro/FourEscape.astro",
26
27
  "./astro/google-map": "./src/astro/GoogleMap.astro",
27
28
  "./astro/image": "./src/astro/Image.astro",
@@ -65,7 +66,7 @@
65
66
  "@biomejs/biome": "2.6.6",
66
67
  "@types/node": "26.6.6",
67
68
  "@vitest/coverage-istanbul": "5.1.0",
68
- "astro": "7.4.7",
69
+ "astro": "7.4.10",
69
70
  "typescript": "6.0.3",
70
71
  "vitest": "5.1.0"
71
72
  }
@@ -52,7 +52,9 @@ export const STORAGE_VENDOR_NAMES = {
52
52
  drip: "Drip",
53
53
  clarity: "Microsoft Clarity",
54
54
  "4escape": "4escape",
55
- } as const satisfies Record<StorageVendor | BookingVendor, string>;
55
+ escapenavigator: "Escape Navigator",
56
+ stripe: "Stripe",
57
+ } as const satisfies Record<StorageVendor | BookingVendor | "stripe", string>;
56
58
 
57
59
  /** A span `Intl.NumberFormat` can word in any locale: `{ 2, "year" }`. */
58
60
  export interface Lifetime {
@@ -69,15 +71,23 @@ export type AtlasStoredId = "atlas-locale" | "atlas-campaign" | "atlas-consent";
69
71
  /** A booking widget's rows: what it keeps, and what it keeps for the tab. */
70
72
  type BookingRow = BookingVendor | `${BookingVendor}-tab`;
71
73
 
74
+ /** Stripe's, for a booking widget that takes payment through it. */
75
+ type StripeRow = "stripe" | "stripe-own";
76
+
72
77
  /** One row's id: what a project keys the row's wording by. */
73
- export type StoredId = AtlasStoredId | StorageVendor | OwnRow | BookingRow;
78
+ export type StoredId =
79
+ | AtlasStoredId
80
+ | StorageVendor
81
+ | OwnRow
82
+ | BookingRow
83
+ | StripeRow;
74
84
 
75
85
  export interface StoredItem {
76
86
  readonly id: StoredId;
77
87
  /** As the browser shows them; a pattern like `_ga_<id>` where the vendor appends one. */
78
88
  readonly names: readonly string[];
79
89
  /** Who sets it: this site, or the vendor. */
80
- readonly provider: "site" | StorageVendor | BookingVendor;
90
+ readonly provider: "site" | StorageVendor | BookingVendor | "stripe";
81
91
  /** Set on the vendor's own domains rather than the site's; absent for the site's. */
82
92
  readonly domains?: readonly string[];
83
93
  /** A span; `tab` for session storage; `kept` until the visitor clears it. */
@@ -267,11 +277,22 @@ const VENDOR_ORDER: readonly StorageVendor[] = [
267
277
  ];
268
278
 
269
279
  /** The vendors a deployment's settings load, in catalog order. */
280
+ /** What a booking widget keeps, and what it brings with it. */
281
+ interface BookingStorage {
282
+ /** In local storage on the site's domain, until cleared. */
283
+ readonly kept: readonly string[];
284
+ /** In session storage, for the tab. */
285
+ readonly tab: readonly string[];
286
+ /** Takes payment through Stripe, whose fraud check sets cookies unasked. */
287
+ readonly stripe?: true;
288
+ /** Tags it loads itself, with the store's own ids, once consent allows. */
289
+ readonly loads?: readonly StorageVendor[];
290
+ }
291
+
270
292
  /**
271
- * Each booking widget's storage, all on the site's domain and none of it
272
- * cookies: what its script left after rendering, seen live 2026-11-11, since
273
- * the vendor documents none. Functional — the language, the cart — so nothing
274
- * has to be granted first.
293
+ * Each booking widget's storage: what its script left after rendering, seen
294
+ * live 2026-11-11, since neither vendor documents it. Its own keys are
295
+ * functional — the language, the cart — so nothing has to be granted first.
275
296
  */
276
297
  const BOOKING = {
277
298
  // `forescapeAnalytics` stayed empty, with no tracker loaded; listed because
@@ -280,10 +301,43 @@ const BOOKING = {
280
301
  kept: ["forescapeLayout", "forescapeStoreOrder", "forescapeAnalytics"],
281
302
  tab: ["forescape"],
282
303
  },
283
- } as const satisfies Record<
284
- BookingVendor,
285
- { readonly kept: readonly string[]; readonly tab: readonly string[] }
286
- >;
304
+ // docs.escapenavigator.com/guides-and-tips/google-analytics-4 for the tags:
305
+ // the store's own, from its Escape Navigator settings.
306
+ escapenavigator: {
307
+ kept: [
308
+ "theme",
309
+ "escapeNavigatorBookingState_v1_<id>",
310
+ "lastExternalReferrer",
311
+ "lastExternalReferrerTime",
312
+ ],
313
+ tab: ["escapenavigator:consent-warned:<state>"],
314
+ stripe: true,
315
+ loads: ["google-analytics", "google-ads", "meta"],
316
+ },
317
+ } as const satisfies Record<BookingVendor, BookingStorage>;
318
+
319
+ /**
320
+ * Stripe's fraud-prevention cookies, which Stripe.js sets whether or not
321
+ * anything is paid: stripe.com/legal/cookies-policy. Seen live 2026-11-11;
322
+ * `m` lasts two years as Stripe states it, which Chrome caps at 400 days.
323
+ */
324
+ const STRIPE_ROWS: readonly StoredItem[] = [
325
+ {
326
+ id: "stripe",
327
+ names: ["__stripe_mid", "__stripe_sid"],
328
+ provider: "stripe",
329
+ lasts: year(1),
330
+ consent: "none",
331
+ },
332
+ {
333
+ id: "stripe-own",
334
+ names: ["m"],
335
+ provider: "stripe",
336
+ domains: ["m.stripe.com"],
337
+ lasts: year(2),
338
+ consent: "none",
339
+ },
340
+ ];
287
341
 
288
342
  function loadedVendors(
289
343
  analytics: AnalyticsSettings | undefined
@@ -391,7 +445,35 @@ export function storedItems(
391
445
  });
392
446
  }
393
447
 
394
- for (const vendor of loadedVendors(analytics)) {
448
+ // Each widget's own rows, and what it brings: tags it loads, which join
449
+ // the site's own below, and Stripe's cookies, listed once however many.
450
+ const vendors = new Set(loadedVendors(analytics));
451
+ const widgetRows: StoredItem[] = [];
452
+ let stripe = false;
453
+ for (const widget of new Set(booking)) {
454
+ const entry: BookingStorage = BOOKING[widget];
455
+ widgetRows.push(
456
+ {
457
+ id: widget,
458
+ names: entry.kept,
459
+ provider: widget,
460
+ lasts: "kept",
461
+ consent: "none",
462
+ },
463
+ {
464
+ id: `${widget}-tab`,
465
+ names: entry.tab,
466
+ provider: widget,
467
+ lasts: "tab",
468
+ consent: "none",
469
+ }
470
+ );
471
+ for (const vendor of entry.loads ?? []) vendors.add(vendor);
472
+ if (entry.stripe) stripe = true;
473
+ }
474
+ if (stripe) widgetRows.push(...STRIPE_ROWS);
475
+
476
+ for (const vendor of vendors) {
395
477
  const entry: VendorStorage = CATALOG[vendor];
396
478
  rows.push({
397
479
  id: vendor,
@@ -412,23 +494,7 @@ export function storedItems(
412
494
  }
413
495
  }
414
496
 
415
- for (const widget of new Set(booking)) {
416
- const entry = BOOKING[widget];
417
- rows.push({
418
- id: widget,
419
- names: entry.kept,
420
- provider: widget,
421
- lasts: "kept",
422
- consent: "none",
423
- });
424
- rows.push({
425
- id: `${widget}-tab`,
426
- names: entry.tab,
427
- provider: widget,
428
- lasts: "tab",
429
- consent: "none",
430
- });
431
- }
497
+ rows.push(...widgetRows);
432
498
 
433
499
  return sort ? rows.toSorted(compareStoredItems) : rows;
434
500
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { HTMLAttributes } from "astro/types";
16
16
  import type { BookingWidget, BookingWidgetVars } from "../booking/widget.ts";
17
+ import EscapeNavigator from "./EscapeNavigator.astro";
17
18
  import FourEscape from "./FourEscape.astro";
18
19
 
19
20
  type Props<W extends BookingWidget = BookingWidget> = HTMLAttributes<"div"> & {
@@ -22,9 +23,11 @@ type Props<W extends BookingWidget = BookingWidget> = HTMLAttributes<"div"> & {
22
23
  readonly locale: string;
23
24
  /** The widget's vendor's CSS variables, set on its widgets. */
24
25
  readonly vars?: BookingWidgetVars<W>;
26
+ /** The site's light or dark look, for a vendor that draws both. */
27
+ readonly colorScheme?: "light" | "dark";
25
28
  };
26
29
 
27
- const { widget, locale, vars, ...attrs } = Astro.props;
30
+ const { widget, locale, vars, colorScheme, ...attrs } = Astro.props;
28
31
  ---
29
32
 
30
33
  {widget.vendor === "4escape" && (
@@ -36,3 +39,11 @@ const { widget, locale, vars, ...attrs } = Astro.props;
36
39
  vars={vars}
37
40
  />
38
41
  )}
42
+ {widget.vendor === "escapenavigator" && (
43
+ <EscapeNavigator
44
+ {...attrs}
45
+ widgetId={widget.widgetId}
46
+ locale={locale}
47
+ colorScheme={colorScheme}
48
+ />
49
+ )}
@@ -0,0 +1,39 @@
1
+ ---
2
+ /**
3
+ * An Escape Navigator schedule: the store's rooms and their free slots,
4
+ * booked in a drawer on the page. See `escape-navigator.ts` for what the
5
+ * widget loads, and how it learns the visitor's consent.
6
+ *
7
+ * `locale` is the page's; `colorScheme` picks the widget's light or dark look,
8
+ * the site's own. Attributes go on the widget's element.
9
+ *
10
+ * ```astro
11
+ * <EscapeNavigator widgetId="W0000000AB" locale={locale} colorScheme="dark" />
12
+ * ```
13
+ */
14
+ import type { HTMLAttributes } from "astro/types";
15
+ import {
16
+ checkEscapeNavigator,
17
+ type EscapeNavigatorWidget,
18
+ escapeNavigatorLanguage,
19
+ } from "../booking/escape-navigator.ts";
20
+
21
+ type Props = HTMLAttributes<"div"> &
22
+ EscapeNavigatorWidget & {
23
+ readonly locale: string;
24
+ readonly colorScheme?: "light" | "dark";
25
+ };
26
+
27
+ const { widgetId, locale, colorScheme = "light", ...attrs } = Astro.props;
28
+
29
+ checkEscapeNavigator({ widgetId }, Astro.url.pathname);
30
+ ---
31
+
32
+ <script src="./escape-navigator-consent.ts"></script>
33
+ <div
34
+ {...attrs}
35
+ data-widget-id={widgetId}
36
+ data-widget-type="schedule"
37
+ data-language={escapeNavigatorLanguage(locale)}
38
+ data-theme={colorScheme}
39
+ ></div>
@@ -0,0 +1,38 @@
1
+ import { ESCAPE_NAVIGATOR_LOADER } from "../booking/escape-navigator.ts";
2
+ import { appendScript } from "./append-script.ts";
3
+ import { CONSENT_EVENT, type ConsentChoices, readConsent } from "./consent.ts";
4
+
5
+ /**
6
+ * Escape Navigator's consent, from the visitor's answer on this site: its
7
+ * analytics tags with ours, its ad tags with marketing. Denied until answered.
8
+ */
9
+ function escapeNavigatorConsent(choices: ConsentChoices | undefined) {
10
+ return {
11
+ analytics: choices?.analytics === "granted",
12
+ ads: choices?.marketing === "granted",
13
+ };
14
+ }
15
+
16
+ interface EscapeNavigatorGlobal {
17
+ __pendingConsent?: { analytics: boolean; ads: boolean };
18
+ setConsent?: (consent: { analytics: boolean; ads: boolean }) => void;
19
+ }
20
+
21
+ const host = window as unknown as { EscapeNavigator?: EscapeNavigatorGlobal };
22
+
23
+ // Before the loader runs, which reads the pending answer as it starts: its
24
+ // tags never load on an answer the visitor has not given.
25
+ host.EscapeNavigator ??= {};
26
+ host.EscapeNavigator.__pendingConsent = escapeNavigatorConsent(readConsent());
27
+
28
+ addEventListener(CONSENT_EVENT, (event) => {
29
+ const consent = escapeNavigatorConsent(
30
+ (event as CustomEvent<ConsentChoices>).detail
31
+ );
32
+ const navigator = host.EscapeNavigator ?? {};
33
+ if (navigator.setConsent) navigator.setConsent(consent);
34
+ else navigator.__pendingConsent = consent;
35
+ });
36
+
37
+ // Once per page, however many widgets: this module runs once.
38
+ appendScript(ESCAPE_NAVIGATOR_LOADER);
@@ -0,0 +1,57 @@
1
+ import { invalidId, UUID } from "../analytics/ids.ts";
2
+
3
+ /**
4
+ * Escape Navigator's booking widget: a store's schedule, every room's free
5
+ * slots, booked in a drawer on the page and paid through Stripe.
6
+ *
7
+ * Plain markup and one loader script. The widget runs the store's own Google
8
+ * Analytics, Google Ads and Meta tags, set up in Escape Navigator, and sends
9
+ * the visitor's email and phone to Google before a purchase (Enhanced
10
+ * Conversions). It loads them only once told the visitor's consent: the
11
+ * component tells it the answer given on this site, before the loader runs
12
+ * and on every change. Stripe's fraud check sets its cookies without asking.
13
+ * All of it is in `storedItems`.
14
+ *
15
+ * The embed is Escape Navigator's documented one. The consent call
16
+ * (`EscapeNavigator.setConsent`) is not documented: it is its loader's own,
17
+ * read 2026-11-11, and seen to hold the tags back until consent is given.
18
+ *
19
+ * @see https://docs.escapenavigator.com/guides-and-tips/use-widgets-like-a-pro
20
+ * @see https://docs.escapenavigator.com/guides-and-tips/google-analytics-4
21
+ */
22
+ export interface EscapeNavigatorWidget {
23
+ /**
24
+ * The widget's id, from its card in Escape Navigator: a UUID, or `W` and
25
+ * nine capitals and digits as older widgets have. A widget for one room is
26
+ * a widget of its own, with its own id.
27
+ */
28
+ readonly widgetId: string;
29
+ }
30
+
31
+ /** Escape Navigator's own CSS variables: none it documents, so none taken. */
32
+ export type EscapeNavigatorVars = Readonly<Partial<Record<string, never>>>;
33
+
34
+ /** The loader every Escape Navigator widget on a page shares. */
35
+ export const ESCAPE_NAVIGATOR_LOADER =
36
+ "https://widget.escapenavigator.com/widget-loader.js";
37
+
38
+ /** The older form of widget id; newer ones are UUIDs. */
39
+ const SHORT_ID = /^W[A-Z0-9]{9}$/;
40
+
41
+ /** Fails the build on an id that is not a widget's: it would render nothing. */
42
+ export function checkEscapeNavigator(
43
+ widget: EscapeNavigatorWidget,
44
+ at: string
45
+ ): void {
46
+ if (!(UUID.test(widget.widgetId) || SHORT_ID.test(widget.widgetId))) {
47
+ invalidId(
48
+ at,
49
+ `Escape Navigator widget id "${widget.widgetId}" is neither a UUID nor "W" and nine capitals and digits, so it would render nothing. Copy it from the widget's card in Escape Navigator.`
50
+ );
51
+ }
52
+ }
53
+
54
+ /** The widget's language for a page's locale: its language subtag. */
55
+ export function escapeNavigatorLanguage(locale: string): string {
56
+ return new Intl.Locale(locale).language;
57
+ }
@@ -1,10 +1,16 @@
1
+ import type {
2
+ EscapeNavigatorVars,
3
+ EscapeNavigatorWidget,
4
+ } from "./escape-navigator.ts";
1
5
  import type { FourEscapeVars, FourEscapeWidget } from "./four-escape.ts";
2
6
 
3
7
  /**
4
8
  * A booking widget a site embeds, by its vendor: what a site's config names,
5
9
  * and what picks the component that draws it.
6
10
  */
7
- export type BookingWidget = { readonly vendor: "4escape" } & FourEscapeWidget;
11
+ export type BookingWidget =
12
+ | ({ readonly vendor: "4escape" } & FourEscapeWidget)
13
+ | ({ readonly vendor: "escapenavigator" } & EscapeNavigatorWidget);
8
14
 
9
15
  /** Who makes a booking widget: what `storedItems` takes for their rows. */
10
16
  export type BookingVendor = BookingWidget["vendor"];
@@ -12,6 +18,7 @@ export type BookingVendor = BookingWidget["vendor"];
12
18
  /** Each vendor's CSS variables, as a page sets them on its widgets. */
13
19
  export interface BookingVendorVars {
14
20
  readonly "4escape": FourEscapeVars;
21
+ readonly escapenavigator: EscapeNavigatorVars;
15
22
  }
16
23
 
17
24
  /** The CSS variables a widget takes: its own vendor's, and no other's. */
package/src/index.ts CHANGED
@@ -55,6 +55,13 @@ export {
55
55
  storedItems,
56
56
  withPurposes,
57
57
  } from "./analytics/storage.ts";
58
+ export {
59
+ checkEscapeNavigator,
60
+ ESCAPE_NAVIGATOR_LOADER,
61
+ type EscapeNavigatorVars,
62
+ type EscapeNavigatorWidget,
63
+ escapeNavigatorLanguage,
64
+ } from "./booking/escape-navigator.ts";
58
65
  export {
59
66
  checkFourEscape,
60
67
  FOUR_ESCAPE_LANGUAGES,