@tokenoftrust/storefront-runner 2.0.4 → 2.0.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.
Files changed (46) hide show
  1. package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
  2. package/apps/storefront/node_modules/.bin/vitest +2 -2
  3. package/apps/storefront/node_modules/.vite/deps/_metadata.json +19 -19
  4. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +3 -3
  5. package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
  6. package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
  7. package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
  8. package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
  9. package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
  10. package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
  11. package/apps/storefront/src/layouts/Layout.astro +64 -6
  12. package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
  13. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
  14. package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
  15. package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
  16. package/apps/storefront/src/lib/rawChrome.ts +73 -14
  17. package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
  18. package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
  19. package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
  20. package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
  21. package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
  22. package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
  23. package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
  24. package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
  25. package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
  26. package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
  27. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
  28. package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
  29. package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
  30. package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
  31. package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
  32. package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
  33. package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
  34. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
  35. package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
  36. package/package.json +1 -1
  37. package/packages/public-runtime/node_modules/.bin/vitest +2 -2
  38. package/packages/public-runtime/src/chrome.ts +18 -7
  39. package/packages/public-runtime/src/index.ts +10 -0
  40. package/packages/public-runtime/src/integration/index.ts +1 -0
  41. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
  42. package/packages/public-runtime/src/interaction-tracking.ts +364 -0
  43. package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
  44. package/scripts/tenant/validate.mjs +306 -1
  45. package/scripts/tot-dev.mjs +59 -3
  46. package/tenants/home/public/js/storefront.js +6 -87
@@ -0,0 +1,23 @@
1
+ -- Directory of per-tenant raw-interaction D1 databases (dt-storage-per-tenant-d1).
2
+ -- Mapping ONLY — no interaction data lands here; the rows live in each tenant's
3
+ -- own database (see src/lib/tracking/interactionSchema.ts). Additive.
4
+ CREATE TABLE `tracking_tenant_databases` (
5
+ `tenant_id` text PRIMARY KEY NOT NULL,
6
+ `database_name` text NOT NULL,
7
+ `database_uuid` text NOT NULL,
8
+ `schema_version` integer NOT NULL,
9
+ `created_at` text NOT NULL,
10
+ `updated_at` text NOT NULL,
11
+ `last_pruned_at` text,
12
+ `pruned_rows` integer DEFAULT 0 NOT NULL,
13
+ `size_bytes` integer,
14
+ `size_checked_at` text
15
+ );
16
+ --> statement-breakpoint
17
+ -- Two tenants sharing one database would defeat structural isolation.
18
+ CREATE UNIQUE INDEX `uq_tracking_tenant_databases_uuid`
19
+ ON `tracking_tenant_databases` (`database_uuid`);
20
+ --> statement-breakpoint
21
+ -- The scheduled prune's work queue: oldest-pruned first.
22
+ CREATE INDEX `idx_tracking_tenant_databases_pruned`
23
+ ON `tracking_tenant_databases` (`last_pruned_at`);
@@ -25,9 +25,9 @@ case `uname -a` in
25
25
  esac
26
26
 
27
27
  if [ -z "$NODE_PATH" ]; then
28
- export NODE_PATH="/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.1_lightningcss@1.33.0/node_modules/vitest/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.1_lightningcss@1.33.0/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/node_modules"
28
+ export NODE_PATH="/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.2_lightningcss@1.33.0/node_modules/vitest/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.2_lightningcss@1.33.0/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/node_modules"
29
29
  else
30
- export NODE_PATH="/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.1_lightningcss@1.33.0/node_modules/vitest/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.1_lightningcss@1.33.0/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/node_modules:$NODE_PATH"
30
+ export NODE_PATH="/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.2_lightningcss@1.33.0/node_modules/vitest/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.2_lightningcss@1.33.0/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/node_modules:$NODE_PATH"
31
31
  fi
32
32
  if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
33
33
  exec "$basedir/node.exe" "$basedir_win/../vitest/vitest.mjs" "$@"
@@ -1,103 +1,103 @@
1
1
  {
2
- "hash": "2d41c62f",
2
+ "hash": "b0be5d68",
3
3
  "configHash": "6038e6d2",
4
- "lockfileHash": "07672895",
5
- "browserHash": "9a70acd8",
4
+ "lockfileHash": "db60c5b3",
5
+ "browserHash": "ec565bb8",
6
6
  "optimized": {
7
7
  "@astrojs/preact/client.js": {
8
8
  "src": "../../../../../node_modules/.pnpm/@astrojs+preact@6.0.5_@babel+core@7.29.7_@types+node@26.4.1_esbuild@0.28.2_jiti@2.7.0_p_9974d7dcb4fcbb2fdffe5819f7140fba/node_modules/@astrojs/preact/dist/client.js",
9
9
  "file": "@astrojs_preact_client__js.js",
10
- "fileHash": "19c809b6",
10
+ "fileHash": "455f86c5",
11
11
  "needsInterop": false
12
12
  },
13
13
  "@astrojs/preact > @preact/signals": {
14
14
  "src": "../../../../../node_modules/.pnpm/@preact+signals@2.11.1_preact@10.29.8/node_modules/@preact/signals/dist/signals.module.js",
15
15
  "file": "@astrojs_preact_n_@preact_signals.js",
16
- "fileHash": "6efc42b1",
16
+ "fileHash": "8c741faa",
17
17
  "needsInterop": false
18
18
  },
19
19
  "@astrojs/preact/server.js": {
20
20
  "src": "../../../../../node_modules/.pnpm/@astrojs+preact@6.0.5_@babel+core@7.29.7_@types+node@26.4.1_esbuild@0.28.2_jiti@2.7.0_p_9974d7dcb4fcbb2fdffe5819f7140fba/node_modules/@astrojs/preact/dist/server.js",
21
21
  "file": "@astrojs_preact_server__js.js",
22
- "fileHash": "405f1563",
22
+ "fileHash": "7505b6c7",
23
23
  "needsInterop": false
24
24
  },
25
25
  "astro > aria-query": {
26
26
  "src": "../../../../../node_modules/.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js",
27
27
  "file": "astro_n_aria-query.js",
28
- "fileHash": "ac27fcae",
28
+ "fileHash": "eda53a8d",
29
29
  "needsInterop": true
30
30
  },
31
31
  "astro > axobject-query": {
32
32
  "src": "../../../../../node_modules/.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js",
33
33
  "file": "astro_n_axobject-query.js",
34
- "fileHash": "d6a1e1fc",
34
+ "fileHash": "3a839b14",
35
35
  "needsInterop": true
36
36
  },
37
37
  "astro > html-escaper": {
38
38
  "src": "../../../../../node_modules/.pnpm/html-escaper@3.0.3/node_modules/html-escaper/esm/index.js",
39
39
  "file": "astro_n_html-escaper.js",
40
- "fileHash": "4eaabdf2",
40
+ "fileHash": "842bf3b2",
41
41
  "needsInterop": false
42
42
  },
43
43
  "astro/runtime/client/dev-toolbar/entrypoint.js": {
44
44
  "src": "../../../../../node_modules/.pnpm/astro@7.2.10_@emnapi+core@1.11.1_@emnapi+runtime@1.11.3_@types+node@26.4.1_jiti@2.7.0_tsx@4.23.13_yaml@2.9.0/node_modules/astro/dist/runtime/client/dev-toolbar/entrypoint.js",
45
45
  "file": "astro_runtime_client_dev-toolbar_entrypoint__js.js",
46
- "fileHash": "24f6e7f9",
46
+ "fileHash": "58367797",
47
47
  "needsInterop": false
48
48
  },
49
49
  "preact": {
50
50
  "src": "../../../../../node_modules/.pnpm/preact@10.29.8_preact-render-to-string@6.7.0/node_modules/preact/dist/preact.module.js",
51
51
  "file": "preact.js",
52
- "fileHash": "733cb1c0",
52
+ "fileHash": "f07c9fbd",
53
53
  "needsInterop": false
54
54
  },
55
55
  "preact/compat": {
56
56
  "src": "../../../../../node_modules/.pnpm/preact@10.29.8_preact-render-to-string@6.7.0/node_modules/preact/compat/dist/compat.module.js",
57
57
  "file": "preact_compat.js",
58
- "fileHash": "517559c2",
58
+ "fileHash": "848429c1",
59
59
  "needsInterop": false
60
60
  },
61
61
  "preact/compat/jsx-runtime": {
62
62
  "src": "../../../../../node_modules/.pnpm/preact@10.29.8_preact-render-to-string@6.7.0/node_modules/preact/compat/jsx-runtime.mjs",
63
63
  "file": "preact_compat_jsx-runtime.js",
64
- "fileHash": "a6e4d5ed",
64
+ "fileHash": "8451bcf5",
65
65
  "needsInterop": false
66
66
  },
67
67
  "preact/debug": {
68
68
  "src": "../../../../../node_modules/.pnpm/preact@10.29.8_preact-render-to-string@6.7.0/node_modules/preact/debug/dist/debug.module.js",
69
69
  "file": "preact_debug.js",
70
- "fileHash": "b40701f0",
70
+ "fileHash": "b7df4cef",
71
71
  "needsInterop": false
72
72
  },
73
73
  "preact/devtools": {
74
74
  "src": "../../../../../node_modules/.pnpm/preact@10.29.8_preact-render-to-string@6.7.0/node_modules/preact/devtools/dist/devtools.module.js",
75
75
  "file": "preact_devtools.js",
76
- "fileHash": "e080fba3",
76
+ "fileHash": "6ceae058",
77
77
  "needsInterop": false
78
78
  },
79
79
  "preact/hooks": {
80
80
  "src": "../../../../../node_modules/.pnpm/preact@10.29.8_preact-render-to-string@6.7.0/node_modules/preact/hooks/dist/hooks.module.js",
81
81
  "file": "preact_hooks.js",
82
- "fileHash": "e0b62eb2",
82
+ "fileHash": "937b89df",
83
83
  "needsInterop": false
84
84
  },
85
85
  "preact/jsx-dev-runtime": {
86
86
  "src": "../../../../../node_modules/.pnpm/preact@10.29.8_preact-render-to-string@6.7.0/node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js",
87
87
  "file": "preact_jsx-dev-runtime.js",
88
- "fileHash": "cf9d7f05",
88
+ "fileHash": "debc9d4c",
89
89
  "needsInterop": false
90
90
  },
91
91
  "preact/jsx-runtime": {
92
92
  "src": "../../../../../node_modules/.pnpm/preact@10.29.8_preact-render-to-string@6.7.0/node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js",
93
93
  "file": "preact_jsx-runtime.js",
94
- "fileHash": "8f9f2b2e",
94
+ "fileHash": "0bafc776",
95
95
  "needsInterop": false
96
96
  },
97
97
  "preact/test-utils": {
98
98
  "src": "../../../../../node_modules/.pnpm/preact@10.29.8_preact-render-to-string@6.7.0/node_modules/preact/test-utils/dist/testUtils.module.js",
99
99
  "file": "preact_test-utils.js",
100
- "fileHash": "1083c021",
100
+ "fileHash": "3f269260",
101
101
  "needsInterop": false
102
102
  }
103
103
  },
@@ -1,8 +1,8 @@
1
1
  {
2
- "hash": "1e6e1930",
2
+ "hash": "d22689c5",
3
3
  "configHash": "d885ab85",
4
- "lockfileHash": "07672895",
5
- "browserHash": "2860703c",
4
+ "lockfileHash": "db60c5b3",
5
+ "browserHash": "89c4e2b4",
6
6
  "optimized": {},
7
7
  "chunks": {}
8
8
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import type { ChromeNavItem } from "@/lib/chrome/model";
11
11
  import NavDropdown from "@/components/chrome/NavDropdown.astro";
12
+ import { CHROME_PLACEMENT } from "@/lib/chrome/trackingPlacements";
12
13
 
13
14
  interface Props {
14
15
  items: ChromeNavItem[];
@@ -21,6 +22,15 @@ const { items, ariaLabel = "Primary", class: className } = Astro.props;
21
22
 
22
23
  <nav aria-label={ariaLabel} class:list={["hidden md:block", className]}>
23
24
  <ul class="flex items-center gap-6">
24
- {items.map((item) => <NavDropdown item={item} />)}
25
+ {
26
+ items.map((item, i) => (
27
+ <NavDropdown
28
+ item={item}
29
+ placement={CHROME_PLACEMENT.primaryNav}
30
+ menuPlacement={CHROME_PLACEMENT.megaMenu}
31
+ index={i}
32
+ />
33
+ ))
34
+ }
25
35
  </ul>
26
36
  </nav>
@@ -18,6 +18,7 @@
18
18
  import type { ChromeNavItem, ChromeLink } from "@/lib/chrome/model";
19
19
  import { withBase } from "@/lib/basePath";
20
20
  import { isCurrentNavHref } from "@/lib/chrome/navCurrent";
21
+ import { trackingAttributes } from "@tot/public-runtime";
21
22
 
22
23
  interface Props {
23
24
  item: ChromeNavItem;
@@ -26,9 +27,19 @@ interface Props {
26
27
  * "accordion" = mobile stacked item using a native <details> disclosure.
27
28
  */
28
29
  mode?: "bar" | "accordion";
30
+ /**
31
+ * `data-tot-placement` for this item's OWN link — explicit, passed by the
32
+ * caller (MegaMenu for mode="bar", SiteHeader's no-JS mobile fallback for
33
+ * mode="accordion"). Never inferred from `mode`.
34
+ */
35
+ placement: string;
36
+ /** `data-tot-placement` for this item's dropdown/mega children. */
37
+ menuPlacement: string;
38
+ /** This item's zero-based position within the top-level nav list (`data-tot-i`, scope "nav-primary"). */
39
+ index: number;
29
40
  }
30
41
 
31
- const { item, mode = "bar" } = Astro.props;
42
+ const { item, mode = "bar", placement, menuPlacement, index } = Astro.props;
32
43
  const { basePath } = Astro.locals;
33
44
 
34
45
  const itemHref = withBase(basePath, item.href);
@@ -44,12 +55,37 @@ const hasMenu =
44
55
  const isMega = !!(item.columns && item.columns.length > 0);
45
56
  // Normalize columns/children into titled groups for the accordion body so the
46
57
  // same markup renders a mega panel (multiple titled columns) or a single-column
47
- // dropdown (one untitled group).
48
- const groups = isMega
58
+ // dropdown (one untitled group). Each link is paired with its flat position
59
+ // within THIS item's repeat container (scope `nav-mega:<item.id>`).
60
+ let flatChildIndex = 0;
61
+ const rawGroups = isMega
49
62
  ? item.columns!.map((c) => ({ title: c.title, links: c.links }))
50
63
  : item.children
51
64
  ? [{ title: undefined as string | undefined, links: item.children }]
52
65
  : [];
66
+ const groups = rawGroups.map((g) => ({
67
+ title: g.title,
68
+ links: g.links.map((link) => ({ link, index: flatChildIndex++ })),
69
+ }));
70
+
71
+ const itemStamp = trackingAttributes({
72
+ el: item.id,
73
+ action: item.action,
74
+ tracking: item.tracking,
75
+ placement,
76
+ scope: "nav-primary",
77
+ index,
78
+ });
79
+ const megaScope = `nav-mega:${item.id}`;
80
+ const childStamp = (link: ChromeLink, childIndex: number) =>
81
+ trackingAttributes({
82
+ el: link.id,
83
+ action: link.action,
84
+ tracking: link.tracking,
85
+ placement: menuPlacement,
86
+ scope: megaScope,
87
+ index: childIndex,
88
+ });
53
89
  ---
54
90
 
55
91
  {
@@ -79,13 +115,14 @@ const groups = isMega
79
115
  </p>
80
116
  )}
81
117
  <ul class="grid gap-0.5">
82
- {col.links.map((child) => (
118
+ {col.links.map(({ link: child, index: childIndex }) => (
83
119
  <li>
84
120
  <a
85
121
  href={hrefOf(child)}
86
122
  aria-current={isChildCurrent(child) ? "page" : undefined}
87
123
  class="flex items-center gap-1.5 rounded-sm px-2 py-1.5 text-sm text-muted transition-colors hover:bg-bg hover:text-primary"
88
124
  {...(child.external ? { rel: "noopener noreferrer" } : { "data-astro-prefetch": true })}
125
+ {...childStamp(child, childIndex)}
89
126
  >
90
127
  {child.label}
91
128
  {child.badge && (
@@ -112,6 +149,7 @@ const groups = isMega
112
149
  "flex items-center gap-1.5 border-b border-border py-2.5 text-sm font-medium text-text transition-colors last:border-b-0 hover:text-accent",
113
150
  item.flag && "text-accent",
114
151
  ]}
152
+ {...itemStamp}
115
153
  >
116
154
  {item.label}
117
155
  {item.badge && (
@@ -133,6 +171,7 @@ const groups = isMega
133
171
  item.flag && "font-semibold text-accent",
134
172
  ]}
135
173
  aria-haspopup="true"
174
+ {...itemStamp}
136
175
  >
137
176
  {item.label}
138
177
  {item.badge && (
@@ -146,7 +185,7 @@ const groups = isMega
146
185
  <div class="invisible absolute left-0 top-full z-50 translate-y-1 opacity-0 transition-all duration-150 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100">
147
186
  {isMega ? (
148
187
  <div class="mt-1 grid gap-6 rounded-md border border-border bg-surface p-5 shadow-md sm:grid-cols-2 lg:grid-cols-3">
149
- {item.columns!.map((col) => (
188
+ {groups.map((col) => (
150
189
  <div class="min-w-44">
151
190
  {col.title && (
152
191
  <p class="mb-2 font-mono text-[0.62rem] uppercase tracking-[0.12em] text-muted">
@@ -154,13 +193,14 @@ const groups = isMega
154
193
  </p>
155
194
  )}
156
195
  <ul class="grid gap-0.5">
157
- {col.links.map((child) => (
196
+ {col.links.map(({ link: child, index: childIndex }) => (
158
197
  <li>
159
198
  <a
160
199
  href={hrefOf(child)}
161
200
  aria-current={isChildCurrent(child) ? "page" : undefined}
162
201
  class="flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm text-muted transition-colors hover:bg-bg hover:text-primary"
163
202
  {...(child.external ? { rel: "noopener noreferrer" } : { "data-astro-prefetch": true })}
203
+ {...childStamp(child, childIndex)}
164
204
  >
165
205
  {child.label}
166
206
  {child.badge && (
@@ -177,13 +217,14 @@ const groups = isMega
177
217
  </div>
178
218
  ) : (
179
219
  <ul class="mt-1 grid min-w-56 gap-0.5 rounded-md border border-border bg-surface p-3 shadow-md">
180
- {item.children!.map((child) => (
220
+ {groups[0]?.links.map(({ link: child, index: childIndex }) => (
181
221
  <li>
182
222
  <a
183
223
  href={hrefOf(child)}
184
224
  aria-current={isChildCurrent(child) ? "page" : undefined}
185
225
  class="block rounded-sm px-3 py-1.5 text-sm text-muted transition-colors hover:bg-bg hover:text-primary"
186
226
  {...(child.external ? { rel: "noopener noreferrer" } : { "data-astro-prefetch": true })}
227
+ {...childStamp(child, childIndex)}
187
228
  >
188
229
  {child.label}
189
230
  </a>
@@ -203,6 +244,7 @@ const groups = isMega
203
244
  "inline-flex items-center gap-1 py-2 text-sm transition-colors hover:text-accent",
204
245
  item.flag && "font-semibold text-accent",
205
246
  ]}
247
+ {...itemStamp}
206
248
  >
207
249
  {item.label}
208
250
  {item.badge && (
@@ -9,6 +9,8 @@
9
9
  */
10
10
  import type { ChromeLink, FooterColumn, FooterNewsletter, SocialLink, ChromeLogo, FooterVariant } from "@/lib/chrome/model";
11
11
  import { withBase } from "@/lib/basePath";
12
+ import { CHROME_PLACEMENT } from "@/lib/chrome/trackingPlacements";
13
+ import { trackingAttributes } from "@tot/public-runtime";
12
14
 
13
15
  interface Props {
14
16
  columns: FooterColumn[];
@@ -46,6 +48,24 @@ const {
46
48
  const { basePath } = Astro.locals;
47
49
  const hrefOf = (l: ChromeLink) => (l.external ? l.href : withBase(basePath, l.href));
48
50
  const showLogo = !!brand?.logo && /^(https?:|\/)/.test(brand.logo.src);
51
+ const columnLinkStamp = (l: ChromeLink, colIndex: number, index: number) =>
52
+ trackingAttributes({
53
+ el: l.id,
54
+ action: l.action,
55
+ tracking: l.tracking,
56
+ placement: CHROME_PLACEMENT.footer,
57
+ scope: `footer-col:${colIndex}`,
58
+ index,
59
+ });
60
+ const bottomLinkStamp = (l: ChromeLink, index: number) =>
61
+ trackingAttributes({
62
+ el: l.id,
63
+ action: l.action,
64
+ tracking: l.tracking,
65
+ placement: CHROME_PLACEMENT.footerBottom,
66
+ scope: "footer-bottom",
67
+ index,
68
+ });
49
69
  ---
50
70
 
51
71
  <footer class:list={["border-t border-border bg-surface text-text", className]} data-variant={variant}>
@@ -116,16 +136,17 @@ const showLogo = !!brand?.logo && /^(https?:|\/)/.test(brand.logo.src);
116
136
 
117
137
  <!-- Link columns -->
118
138
  {
119
- columns.map((col) => (
139
+ columns.map((col, colIndex) => (
120
140
  <div>
121
141
  <h3 class="font-display text-sm font-semibold">{col.title}</h3>
122
142
  <ul class="mt-3 grid gap-2">
123
- {col.links.map((l) => (
143
+ {col.links.map((l, i) => (
124
144
  <li>
125
145
  <a
126
146
  href={hrefOf(l)}
127
147
  class="text-sm text-muted transition-colors hover:text-primary"
128
148
  {...(l.external ? { rel: "noopener noreferrer" } : {})}
149
+ {...columnLinkStamp(l, colIndex, i)}
129
150
  >
130
151
  {l.label}
131
152
  </a>
@@ -198,9 +219,13 @@ const showLogo = !!brand?.logo && /^(https?:|\/)/.test(brand.logo.src);
198
219
  bottomLinks.length > 0 && (
199
220
  <nav aria-label="Legal">
200
221
  <ul class="flex flex-wrap gap-x-5 gap-y-1">
201
- {bottomLinks.map((l) => (
222
+ {bottomLinks.map((l, i) => (
202
223
  <li>
203
- <a href={hrefOf(l)} class="transition-colors hover:text-primary">
224
+ <a
225
+ href={hrefOf(l)}
226
+ class="transition-colors hover:text-primary"
227
+ {...bottomLinkStamp(l, i)}
228
+ >
204
229
  {l.label}
205
230
  </a>
206
231
  </li>
@@ -19,6 +19,9 @@ import NavDropdown from "@/components/chrome/NavDropdown.astro";
19
19
  import SearchTypeahead from "@/components/islands/SearchTypeahead.tsx";
20
20
  import MobileNav from "@/components/islands/MobileNav.tsx";
21
21
  import { withBase } from "@/lib/basePath";
22
+ import { ctaIdentityAttr } from "@/lib/chrome/ctaIdentity";
23
+ import { CHROME_PLACEMENT } from "@/lib/chrome/trackingPlacements";
24
+ import { trackingAttributes } from "@tot/public-runtime";
22
25
 
23
26
  interface Props {
24
27
  logo?: ChromeLogo;
@@ -97,11 +100,17 @@ const ctaClass = (v?: string) =>
97
100
  // source produces `columns` today; NavDropdown's accordion mode is the richer
98
101
  // zero-JS alternative if per-column grouping is ever needed on mobile.
99
102
  const mobileNav = nav.map((item) => ({
103
+ id: item.id,
100
104
  label: item.label,
101
105
  href: withBase(basePath, item.href),
106
+ action: item.action,
107
+ tracking: item.tracking,
102
108
  children: (item.children ?? item.columns?.flatMap((c) => c.links))?.map((child) => ({
109
+ id: child.id,
103
110
  label: child.label,
104
111
  href: withBase(basePath, child.href),
112
+ action: child.action,
113
+ tracking: child.tracking,
105
114
  })),
106
115
  }));
107
116
 
@@ -186,11 +195,27 @@ const currentPath = Astro.url.pathname;
186
195
  {
187
196
  !minimal && headerCtas.length > 0 && (
188
197
  <div class="hidden items-center gap-2 sm:flex">
189
- {headerCtas.map((cta) => (
190
- <a href={withBase(basePath, cta.href)} class={ctaClass(cta.variant)}>
191
- {cta.label}
192
- </a>
193
- ))}
198
+ {headerCtas.map((cta, i) => {
199
+ const idAttr = ctaIdentityAttr(cta);
200
+ const stamp = trackingAttributes({
201
+ el: cta.id,
202
+ action: cta.action,
203
+ tracking: cta.tracking,
204
+ placement: CHROME_PLACEMENT.header,
205
+ scope: "header-ctas",
206
+ index: i,
207
+ });
208
+ return (
209
+ <a
210
+ href={withBase(basePath, cta.href)}
211
+ class={ctaClass(cta.variant)}
212
+ {...{ [idAttr.name]: idAttr.value }}
213
+ {...stamp}
214
+ >
215
+ {cta.label}
216
+ </a>
217
+ );
218
+ })}
194
219
  </div>
195
220
  )
196
221
  }
@@ -269,7 +294,15 @@ const currentPath = Astro.url.pathname;
269
294
  <MegaMenu items={nav} />
270
295
  <nav aria-label="Mobile" data-mobile-nav-fallback class="md:hidden">
271
296
  <ul class="grid">
272
- {nav.map((item) => <NavDropdown item={item} mode="accordion" />)}
297
+ {nav.map((item, i) => (
298
+ <NavDropdown
299
+ item={item}
300
+ mode="accordion"
301
+ placement={CHROME_PLACEMENT.mobileMenu}
302
+ menuPlacement={CHROME_PLACEMENT.mobileMenu}
303
+ index={i}
304
+ />
305
+ ))}
273
306
  </ul>
274
307
  </nav>
275
308
  </div>
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import type { ChromeLink, SocialLink } from "@/lib/chrome/model";
9
9
  import { withBase } from "@/lib/basePath";
10
+ import { CHROME_PLACEMENT } from "@/lib/chrome/trackingPlacements";
11
+ import { trackingAttributes } from "@tot/public-runtime";
10
12
 
11
13
  interface Props {
12
14
  links?: ChromeLink[];
@@ -19,6 +21,15 @@ interface Props {
19
21
  const { links = [], locale, currency, social = [], class: className } = Astro.props;
20
22
  const { basePath } = Astro.locals;
21
23
  const hrefOf = (l: ChromeLink) => (l.external ? l.href : withBase(basePath, l.href));
24
+ const linkStamp = (l: ChromeLink, index: number) =>
25
+ trackingAttributes({
26
+ el: l.id,
27
+ action: l.action,
28
+ tracking: l.tracking,
29
+ placement: CHROME_PLACEMENT.utilityNav,
30
+ scope: "utility-nav",
31
+ index,
32
+ });
22
33
  ---
23
34
 
24
35
  <div
@@ -30,12 +41,13 @@ const hrefOf = (l: ChromeLink) => (l.external ? l.href : withBase(basePath, l.hr
30
41
  <div class="container-prose flex h-9 items-center justify-between gap-4 text-[0.72rem]">
31
42
  <ul class="flex items-center gap-5">
32
43
  {
33
- links.map((l) => (
44
+ links.map((l, i) => (
34
45
  <li>
35
46
  <a
36
47
  href={hrefOf(l)}
37
48
  class="transition-colors hover:text-primary"
38
49
  {...(l.external ? { rel: "noopener noreferrer" } : {})}
50
+ {...linkStamp(l, i)}
39
51
  >
40
52
  {l.label}
41
53
  </a>
@@ -12,14 +12,30 @@
12
12
  */
13
13
  import { useCallback, useEffect, useRef, useState } from "preact/hooks";
14
14
  import { isCurrentNavHref } from "@/lib/chrome/navCurrent";
15
+ import { CHROME_PLACEMENT } from "@/lib/chrome/trackingPlacements";
16
+ import { trackingAttributes } from "@tot/public-runtime";
15
17
 
18
+ /**
19
+ * `action`/`tracking` are declared loosely here (not the strict
20
+ * `TrackingDeclaration` union `SiteHeader.astro` maps from) — a fresh object
21
+ * literal spreading a discriminated union's fields widens to two independent
22
+ * optional properties, so TS can no longer see "exactly one of the two" at
23
+ * the type level. `trackingAttributes()` is still the runtime oracle: it
24
+ * throws on an invalid combination regardless of what the type allows.
25
+ */
16
26
  interface NavChild {
27
+ id: string;
17
28
  label: string;
18
29
  href: string;
30
+ action?: string;
31
+ tracking?: "none";
19
32
  }
20
33
  interface NavItem {
34
+ id: string;
21
35
  label: string;
22
36
  href: string;
37
+ action?: string;
38
+ tracking?: "none";
23
39
  children?: NavChild[];
24
40
  }
25
41
 
@@ -212,7 +228,7 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
212
228
 
213
229
  <nav aria-label="Mobile" class="flex-1 px-2 py-4">
214
230
  <ul class="space-y-1">
215
- {nav.map((item) => (
231
+ {nav.map((item, i) => (
216
232
  <li key={item.href}>
217
233
  {item.children && item.children.length > 0 ? (
218
234
  <div class="px-3 py-2">
@@ -222,11 +238,19 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
222
238
  currentPath && isCurrentNavHref(currentPath, item.href) ? "page" : undefined
223
239
  }
224
240
  class="block font-display text-base font-medium text-text hover:text-primary"
241
+ {...trackingAttributes({
242
+ el: item.id,
243
+ action: item.action,
244
+ tracking: item.tracking,
245
+ placement: CHROME_PLACEMENT.mobileMenu,
246
+ scope: "nav-mobile",
247
+ index: i,
248
+ })}
225
249
  >
226
250
  {item.label}
227
251
  </a>
228
252
  <ul class="mt-1 space-y-0.5 border-l border-border pl-3">
229
- {item.children.map((child) => (
253
+ {item.children.map((child, ci) => (
230
254
  <li key={child.href}>
231
255
  <a
232
256
  href={child.href}
@@ -234,6 +258,14 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
234
258
  currentPath && isCurrentNavHref(currentPath, child.href) ? "page" : undefined
235
259
  }
236
260
  class="block rounded px-2 py-1.5 font-body text-sm text-muted hover:bg-border/40 hover:text-text"
261
+ {...trackingAttributes({
262
+ el: child.id,
263
+ action: child.action,
264
+ tracking: child.tracking,
265
+ placement: CHROME_PLACEMENT.mobileMenu,
266
+ scope: `nav-mobile-mega:${item.id}`,
267
+ index: ci,
268
+ })}
237
269
  >
238
270
  {child.label}
239
271
  </a>
@@ -248,6 +280,14 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
248
280
  currentPath && isCurrentNavHref(currentPath, item.href) ? "page" : undefined
249
281
  }
250
282
  class="block rounded px-3 py-2.5 font-display text-base font-medium text-text hover:bg-border/40 hover:text-primary"
283
+ {...trackingAttributes({
284
+ el: item.id,
285
+ action: item.action,
286
+ tracking: item.tracking,
287
+ placement: CHROME_PLACEMENT.mobileMenu,
288
+ scope: "nav-mobile",
289
+ index: i,
290
+ })}
251
291
  >
252
292
  {item.label}
253
293
  </a>