@getvitops/astro 8.0.1 → 8.2.0-alpha.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/src/ads.ts CHANGED
@@ -25,7 +25,13 @@
25
25
  * Everything decidable is here and unit-tested; `Ads.astro` renders what this
26
26
  * returns and chooses nothing.
27
27
  */
28
- import { AD_PLATFORMS, AD_PROVIDER_KEYS, type AdProvider } from '@getvitops/utils/ads';
28
+ import {
29
+ AD_PLATFORMS,
30
+ AD_PROVIDER_KEYS,
31
+ normalizeConversionLabels,
32
+ type AdProvider,
33
+ type ConversionLabelMap,
34
+ } from '@getvitops/utils/ads';
29
35
  import type { AnalyticsStrategy, ConsentCategory, GatedTag } from './analytics.ts';
30
36
 
31
37
  /**
@@ -38,7 +44,15 @@ import type { AnalyticsStrategy, ConsentCategory, GatedTag } from './analytics.t
38
44
  export interface GetvitopsAdProperty {
39
45
  accountId?: string;
40
46
  pixelId?: string;
41
- conversionLabel?: string;
47
+ /**
48
+ * Google Ads only: the conversion action label(s) paired with `pixelId` (the
49
+ * `AW-…` conversion id) — a bare string (back-compat, treated as `call`) or a
50
+ * map of action → label. `<Tracking />`'s tel:/mailto: click handler and
51
+ * `vitopsFormConversion()` each fire `gtag('event', 'conversion', …)` for
52
+ * their own action at the moment of the interaction — see `gtagBootstrap` in
53
+ * `@getvitops/utils/ads`.
54
+ */
55
+ conversionLabel?: string | ConversionLabelMap;
42
56
  /** Default `marketing`. `analytics` for a property used purely for measurement. */
43
57
  category?: 'marketing' | 'analytics';
44
58
  /** False keeps the property on record without emitting its tag. */
@@ -113,6 +127,35 @@ export function resolveAds(
113
127
  continue;
114
128
  }
115
129
 
130
+ // `conversionLabel` lands inside the same inline `<script>` as `id` (Google's
131
+ // bootstrap embeds it to expose a conversion `send_to` target per action) —
132
+ // same injection surface, so every configured label gets the same check.
133
+ // Unlike an invalid `id`, an invalid label doesn't sink the whole tag: it
134
+ // just means that one action's conversion doesn't fire, which is worth a
135
+ // warning rather than dropping every other configured action too.
136
+ let safeEntry = entry;
137
+ const labels = normalizeConversionLabels(entry.conversionLabel);
138
+ const invalidActions = Object.entries(labels)
139
+ .filter(([, label]) => label && !SAFE_ID.test(label))
140
+ .map(([action]) => action);
141
+ if (invalidActions.length) {
142
+ for (const action of invalidActions) {
143
+ warnings.push(
144
+ `ads: ${provider} conversionLabel.${action} "${labels[action as keyof ConversionLabelMap]}" ` +
145
+ 'is not a valid label. Conversion firing skipped for that action.',
146
+ );
147
+ }
148
+ const cleanLabels = Object.fromEntries(
149
+ Object.entries(labels).filter(([action]) => !invalidActions.includes(action)),
150
+ );
151
+ // Omit the key entirely rather than setting it `undefined` —
152
+ // `exactOptionalPropertyTypes` treats the two differently.
153
+ const { conversionLabel: _drop, ...rest } = entry;
154
+ safeEntry = Object.keys(cleanLabels).length
155
+ ? { ...rest, conversionLabel: cleanLabels }
156
+ : rest;
157
+ }
158
+
116
159
  const category: ConsentCategory = entry.category ?? 'marketing';
117
160
  tags.push({
118
161
  key: provider,
@@ -125,7 +168,7 @@ export function resolveAds(
125
168
  cookies: platform.tag.cookies,
126
169
  strategy,
127
170
  gated: ctx.consent === true,
128
- inline: platform.tag.bootstrap(id, { provider, ...entry }),
171
+ inline: platform.tag.bootstrap(id, { provider, ...safeEntry }),
129
172
  src: platform.tag.src ? platform.tag.src(id) : null,
130
173
  attrs: {},
131
174
  });
package/src/analytics.ts CHANGED
@@ -156,6 +156,39 @@ export interface ResolvedAnalytics {
156
156
  export interface ResolveAnalyticsContext {
157
157
  /** Is the consent gate enabled on this site (`vitops({ consent })`)? */
158
158
  consent?: boolean;
159
+ /**
160
+ * Enabled `site.experiments` entries, keyed the same way, each carrying the
161
+ * resolved cookie name `vitopsAbTesting()`'s middleware assigns
162
+ * (`_ab_<key>` unless overridden). When set, each provider's bootstrap
163
+ * reads these cookies client-side and reports the assignment via that
164
+ * provider's own custom-dimension/property mechanism — a no-op when this
165
+ * is empty, so a site not using experiments sees no behavior change.
166
+ */
167
+ experiments?: Record<string, { cookieName: string }>;
168
+ /** `site.analytics.matomo.abVariantDimensions` — Matomo's pre-created custom-dimension index per experiment key. */
169
+ matomoAbVariantDimensions?: Record<string, number>;
170
+ }
171
+
172
+ /**
173
+ * A small inline snippet reading each experiment's cookie into a plain
174
+ * object, skipping any experiment whose cookie isn't set (visitor not yet
175
+ * assigned, or the experiment disabled after this page was cached). Shared
176
+ * across providers so the cookie-parsing logic — and its `SAFE_ID` guard on
177
+ * both the experiment key and cookie name, the same discipline `bad()`
178
+ * already applies to provider IDs landing in an inline `<script>` — exists
179
+ * exactly once.
180
+ */
181
+ function experimentCookieReads(experiments: Record<string, { cookieName: string }>): string {
182
+ const entries = Object.entries(experiments).filter(([key, e]) => !bad(key) && !bad(e.cookieName));
183
+ if (!entries.length) return '';
184
+ return (
185
+ `function _abrc(n){var m=document.cookie.match(new RegExp('(?:^|; )'+n+'=([^;]*)'));return m?decodeURIComponent(m[1]):null;}` +
186
+ `var _ab={};` +
187
+ entries
188
+ .map(([key, e]) => `_ab[${JSON.stringify(key)}]=_abrc(${JSON.stringify(e.cookieName)});`)
189
+ .join('') +
190
+ `for(var _k in _ab){if(_ab[_k]==null)delete _ab[_k];}`
191
+ );
159
192
  }
160
193
 
161
194
  /**
@@ -175,10 +208,23 @@ function literal(value: Record<string, string | number | boolean>): string {
175
208
  return JSON.stringify(value).replaceAll('<', '\\u003c');
176
209
  }
177
210
 
178
- function googleAnalytics(input: string | GoogleAnalyticsOptions): ResolvedTag | string {
211
+ function googleAnalytics(
212
+ input: string | GoogleAnalyticsOptions,
213
+ experiments: Record<string, { cookieName: string }> = {},
214
+ ): ResolvedTag | string {
179
215
  const opts = typeof input === 'string' ? { id: input } : input;
180
216
  if (bad(opts.id)) return `googleAnalytics: "${opts.id}" is not a valid measurement ID. Skipped.`;
181
- const config = opts.config ? `,${literal(opts.config)}` : '';
217
+ const staticConfig = opts.config ?? {};
218
+ const reads = experimentCookieReads(experiments);
219
+ // With no experiments configured, the config object is static, exactly as
220
+ // before — same output byte-for-byte, no runtime cost added. With
221
+ // experiments, the automatic pageview's `gtag('config', …)` call is built
222
+ // at runtime instead, merging each assigned variant in under its
223
+ // experiment key so it reaches GA4 as a custom parameter on that same hit
224
+ // (registered as a custom dimension in GA4 Admin by parameter name).
225
+ const gtagConfig = reads
226
+ ? `${reads}gtag('config','${opts.id}',Object.assign(${literal(staticConfig)},_ab));`
227
+ : `gtag('config','${opts.id}'${opts.config ? `,${literal(staticConfig)}` : ''});`;
182
228
  return {
183
229
  key: 'googleAnalytics',
184
230
  provider: 'Google Analytics',
@@ -191,7 +237,7 @@ function googleAnalytics(input: string | GoogleAnalyticsOptions): ResolvedTag |
191
237
  `window.dataLayer=window.dataLayer||[];` +
192
238
  `function gtag(){dataLayer.push(arguments)}` +
193
239
  `gtag('js',new Date());` +
194
- `gtag('config','${opts.id}'${config});`,
240
+ gtagConfig,
195
241
  src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(opts.id)}`,
196
242
  attrs: {},
197
243
  };
@@ -230,7 +276,11 @@ function clarity(input: string | ClarityOptions): ResolvedTag | string {
230
276
  };
231
277
  }
232
278
 
233
- function matomo(opts: MatomoOptions): ResolvedTag | string {
279
+ function matomo(
280
+ opts: MatomoOptions,
281
+ experiments: Record<string, { cookieName: string }> = {},
282
+ dimensions: Record<string, number> = {},
283
+ ): ResolvedTag | string {
234
284
  const siteId = String(opts.siteId);
235
285
  if (bad(siteId)) return `matomo: "${siteId}" is not a valid site ID. Skipped.`;
236
286
  let base: URL;
@@ -242,8 +292,26 @@ function matomo(opts: MatomoOptions): ResolvedTag | string {
242
292
  const url = `${base.origin}${base.pathname.replace(/\/*$/, '')}/`;
243
293
  const cookies = opts.cookies === true;
244
294
 
245
- // `disableCookies` must be pushed *before* trackPageView, or the first request
246
- // of the visit sets the cookies this option exists to avoid.
295
+ // Only experiments with a recorded dimension index push anything — one
296
+ // with no index is silently skipped here (validateConfig's
297
+ // `experimentDimensionWarnings` is what surfaces that as a build-time
298
+ // warning, not a hard error: the pageview still ships correctly).
299
+ const dimensionEntries = Object.entries(experiments).filter(([key]) => dimensions[key] != null);
300
+ const reads = experimentCookieReads(Object.fromEntries(dimensionEntries));
301
+ const dimensionPushes = reads
302
+ ? reads +
303
+ dimensionEntries
304
+ .map(
305
+ ([key]) =>
306
+ `if(_ab[${JSON.stringify(key)}]!=null)_paq.push(['setCustomDimension',${dimensions[key]},_ab[${JSON.stringify(key)}]]);`,
307
+ )
308
+ .join('')
309
+ : '';
310
+
311
+ // `disableCookies` and the experiment dimension pushes must both land
312
+ // *before* trackPageView — the former so the first request of the visit
313
+ // doesn't set the cookies it exists to avoid, the latter because a
314
+ // dimension pushed after trackPageView misses that pageview entirely.
247
315
  return {
248
316
  key: 'matomo',
249
317
  provider: 'Matomo',
@@ -255,6 +323,7 @@ function matomo(opts: MatomoOptions): ResolvedTag | string {
255
323
  inline:
256
324
  `var _paq=window._paq=window._paq||[];` +
257
325
  (cookies ? '' : `_paq.push(['disableCookies']);`) +
326
+ dimensionPushes +
258
327
  `_paq.push(['trackPageView']);_paq.push(['enableLinkTracking']);` +
259
328
  `(function(){var u="${url}";` +
260
329
  `_paq.push(['setTrackerUrl',u+'matomo.php']);_paq.push(['setSiteId','${siteId}']);` +
@@ -265,9 +334,24 @@ function matomo(opts: MatomoOptions): ResolvedTag | string {
265
334
  };
266
335
  }
267
336
 
268
- function plausible(input: string | PlausibleOptions): ResolvedTag | string {
337
+ function plausible(
338
+ input: string | PlausibleOptions,
339
+ experiments: Record<string, { cookieName: string }> = {},
340
+ ): ResolvedTag | string {
269
341
  const opts = typeof input === 'string' ? { domain: input } : input;
270
342
  if (bad(opts.domain)) return `plausible: "${opts.domain}" is not a valid domain. Skipped.`;
343
+ // Plausible's current API (plausible.init({customProperties})) needs no
344
+ // special script variant and no manual-pageview mode — customProperties
345
+ // attaches to every tracked event, including the automatic pageview, so
346
+ // there's no double-counting risk to design around. The queueing stub
347
+ // mirrors gtag's own inline bootstrap above: the real script (loaded via
348
+ // `src`, async) picks up this call once it's ready, regardless of which
349
+ // one finishes loading first.
350
+ const reads = experimentCookieReads(experiments);
351
+ const inline = reads
352
+ ? `window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};` +
353
+ `plausible.init({customProperties:function(){${reads}return _ab;}});`
354
+ : '';
271
355
  return {
272
356
  key: 'plausible',
273
357
  provider: 'Plausible Analytics',
@@ -278,7 +362,7 @@ function plausible(input: string | PlausibleOptions): ResolvedTag | string {
278
362
  cookies: [],
279
363
  strategy: 'idle',
280
364
  gated: true,
281
- inline: '',
365
+ inline,
282
366
  src: opts.src ?? 'https://plausible.io/js/script.js',
283
367
  attrs: { 'data-domain': opts.domain },
284
368
  };
@@ -300,11 +384,14 @@ export function resolveAnalytics(
300
384
  if (!opts) return { tags, needsRuntime: false, warnings };
301
385
 
302
386
  const strategy = opts.strategy ?? 'idle';
387
+ const experiments = ctx.experiments ?? {};
303
388
  const results = [
304
- opts.googleAnalytics === undefined ? null : googleAnalytics(opts.googleAnalytics),
389
+ opts.googleAnalytics === undefined ? null : googleAnalytics(opts.googleAnalytics, experiments),
305
390
  opts.clarity === undefined ? null : clarity(opts.clarity),
306
- opts.matomo === undefined ? null : matomo(opts.matomo),
307
- opts.plausible === undefined ? null : plausible(opts.plausible),
391
+ opts.matomo === undefined
392
+ ? null
393
+ : matomo(opts.matomo, experiments, ctx.matomoAbVariantDimensions ?? {}),
394
+ opts.plausible === undefined ? null : plausible(opts.plausible, experiments),
308
395
  ];
309
396
 
310
397
  for (const result of results) {
@@ -29,7 +29,11 @@ const { enabled = true, strategy } = Astro.props;
29
29
 
30
30
  const options = strategy ? { ...head.analytics, strategy } : head.analytics;
31
31
  const { tags } = enabled
32
- ? resolveAnalytics(options, { consent: head.consent })
32
+ ? resolveAnalytics(options, {
33
+ consent: head.consent,
34
+ experiments: head.experiments,
35
+ matomoAbVariantDimensions: head.matomoAbVariantDimensions,
36
+ })
33
37
  : { tags: [] };
34
38
  ---
35
39
 
@@ -27,8 +27,15 @@
27
27
  * `inline-flex` with a `gap` — is a matter of child order, not a new class.
28
28
  * Don't confuse this with `.icon-mask`, the CSS-only adornment path (see
29
29
  * `.link--icon::before` in anchor-link.css).
30
+ *
31
+ * **Imports from `@getvitops/generator/icons-sprite`, never the root entry.**
32
+ * The root entry also re-exports `generate.ts`, which reaches lightningcss —
33
+ * a native binary Cloudflare Workers cannot `require()`. A *static* import of
34
+ * the root here dragged that into every SSR bundle rendering this component,
35
+ * regardless of `icons.engine`, and 500'd every request on a real consumer.
36
+ * The `icons-sprite` subpath has no such dependency — see its own docblock.
30
37
  */
31
- import { loadIconSvg, spriteId } from '@getvitops/generator';
38
+ import { loadIconSvg, spriteId } from '@getvitops/generator/icons-sprite';
32
39
  import { resolveIcon } from '@getvitops/utils';
33
40
  import icons from 'virtual:getvitops/icons';
34
41
 
@@ -14,8 +14,12 @@
14
14
  * rule as `Counter.astro`/`Carousel.astro`.
15
15
  *
16
16
  * Put the items in the default slot — a list of `<ReviewCard>`s, logos,
17
- * whatever scrolls. They become the one `.marquee__content` copy the element
18
- * clones from.
17
+ * whatever scrolls. `marquee.css`'s CSS-only path needs more than one
18
+ * `.marquee__content` to loop seamlessly with no JS, so this renders the slot
19
+ * into **three** `aria-hidden`-on-the-extras copies (enough to cover a wide
20
+ * track under the `min-inline-size: 100%` floor). `<wc-marquee>` treats the
21
+ * first as its source and discards the other two as soon as it upgrades,
22
+ * replacing them with however many its own measurement calls for.
19
23
  */
20
24
  import type { HTMLAttributes } from 'astro/types';
21
25
 
@@ -60,4 +64,10 @@ const Shell = enhance ? 'wc-marquee' : 'div';
60
64
  <div class="marquee__content">
61
65
  <slot />
62
66
  </div>
67
+ <div class="marquee__content" aria-hidden="true">
68
+ <slot />
69
+ </div>
70
+ <div class="marquee__content" aria-hidden="true">
71
+ <slot />
72
+ </div>
63
73
  </Shell>
@@ -25,7 +25,7 @@
25
25
  // @ts-ignore — virtual module provided by the vitops() integration at build time
26
26
  import head from 'virtual:getvitops/head';
27
27
 
28
- const { enabled, category, cookies } = head.tracking ?? { enabled: false };
28
+ const { enabled, category, cookies, abVariantCookie } = head.tracking ?? { enabled: false };
29
29
  ---
30
30
 
31
31
  {enabled && (
@@ -35,6 +35,7 @@ const { enabled, category, cookies } = head.tracking ?? { enabled: false };
35
35
  data-vitops-tracking
36
36
  data-consent={category}
37
37
  data-consent-cookies={cookies?.length ? cookies.join(',') : undefined}
38
+ data-ab-cookie={abVariantCookie ?? undefined}
38
39
  />
39
40
  <script>
40
41
  import '../scripts/tracking.ts';
@@ -1,8 +1,10 @@
1
1
  // Ad conversion tracking: capture click IDs from the landing URL, track tel:
2
- // clicks. Form submission itself goes through `createConversionRoute()`
3
- // (`@getvitops/astro/routes`), which handles both the no-JS `<form
4
- // method="post">` path and a fetch-based one — there is no separate
5
- // form-enhancement script here.
2
+ // and mailto: clicks. Form submission itself goes through
3
+ // `createConversionRoute()` (`@getvitops/astro/routes`), which handles both
4
+ // the no-JS `<form method="post">` path and a fetch-based one — there is no
5
+ // form-enhancement script here, only `window.vitopsFormConversion()` (below),
6
+ // which a consumer's own fetch-based submit handler may call once that POST
7
+ // succeeds, to report the `form` Google Ads conversion action client-side.
6
8
  //
7
9
  // Runs early and ungated on purpose. Reading the query string is not storage and
8
10
  // needs nobody's permission; *keeping* it does. So the capture is synchronous —
@@ -27,9 +29,12 @@
27
29
 
28
30
  const COOKIE_NAME = '_ac';
29
31
  const COOKIE_DAYS = 90;
30
- const AB_COOKIE = '_ab';
32
+ // Default when no experiment is configured, or the marker (below) carries no
33
+ // `data-ab-cookie` override — kept as a literal fallback so a site with no
34
+ // `site.experiments` behaves exactly as before.
35
+ const DEFAULT_AB_COOKIE = '_ab';
31
36
 
32
- // Where the `tel:` conversion beacon goes. Mirrors TRACKING_ENDPOINT in
37
+ // Where the `tel:`/`mailto:` conversion beacons go. Mirrors TRACKING_ENDPOINT in
33
38
  // ../tracking.ts (pinned by `tracking.test.ts`), copied for the same reason as
34
39
  // the parameter lists below — this file is inlined into the document.
35
40
  //
@@ -70,17 +75,35 @@ for (const key of [...CLICK_ID_PARAMS, ...UTM_PARAMS]) {
70
75
  if (value) trackingData[key] = value;
71
76
  }
72
77
 
73
- // Include A/B variant if assigned by the router Worker. A cookie present but
74
- // valueless yields undefined, which must not be written into the record.
75
- const abMatch = document.cookie.split(';').find((c) => c.trim().startsWith(`${AB_COOKIE}=`));
76
- const abVariant = abMatch?.split('=')[1]?.trim();
78
+ /**
79
+ * Reads a raw cookie value by name. Splits on the *first* `=` only (a prior
80
+ * version split on every `=`, silently truncating any value containing one)
81
+ * and URL-decodes the result — a prior version didn't, so a variant name or
82
+ * attribution value containing a reserved character came back mangled.
83
+ */
84
+ function rawCookie(name: string): string | undefined {
85
+ const match = document.cookie.split(';').find((c) => c.trim().startsWith(`${name}=`));
86
+ if (!match) return undefined;
87
+ const eq = match.indexOf('=');
88
+ const value = match.slice(eq + 1).trim();
89
+ return value ? decodeURIComponent(value) : undefined;
90
+ }
91
+
92
+ // <Tracking /> writes the experiment cookie name onto this marker (the first
93
+ // *enabled* `site.experiments` entry — see `readSiteExperiments`'s own
94
+ // docblock for why only one reaches the lead-attribution pipeline), so a
95
+ // site with no experiments configured falls back to the same default this
96
+ // always used.
97
+ const trackingMarker = document.querySelector('[data-vitops-tracking]');
98
+ const abCookieName = trackingMarker?.getAttribute('data-ab-cookie') || DEFAULT_AB_COOKIE;
99
+ const abVariant = rawCookie(abCookieName);
77
100
  if (abVariant) trackingData['ab_variant'] = abVariant;
78
101
 
79
102
  function readCookie(): Record<string, unknown> | null {
80
- const match = document.cookie.split(';').find((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
81
- if (!match) return null;
103
+ const raw = rawCookie(COOKIE_NAME);
104
+ if (!raw) return null;
82
105
  try {
83
- const data: unknown = JSON.parse(decodeURIComponent(match.split('=').slice(1).join('=')));
106
+ const data: unknown = JSON.parse(raw);
84
107
  return typeof data === 'object' && data !== null && !Array.isArray(data)
85
108
  ? (data as Record<string, unknown>)
86
109
  : null;
@@ -121,10 +144,9 @@ const gate = (): ConsentLike | undefined =>
121
144
  (window as unknown as { vitopsConsent?: ConsentLike }).vitopsConsent;
122
145
 
123
146
  if (Object.keys(trackingData).length > 0) {
124
- // <Tracking /> writes the category onto this marker, which also carries
125
- // `data-consent-cookies` so revoking clears `_ac`.
126
- const marker = document.querySelector('[data-vitops-tracking]');
127
- const category = marker?.getAttribute('data-consent') ?? 'marketing';
147
+ // <Tracking /> writes the category onto this marker (looked up once,
148
+ // above), which also carries `data-consent-cookies` so revoking clears `_ac`.
149
+ const category = trackingMarker?.getAttribute('data-consent') ?? 'marketing';
128
150
 
129
151
  if (!gate()) {
130
152
  // No gate on this site: behave as it always has.
@@ -147,15 +169,86 @@ if (Object.keys(trackingData).length > 0) {
147
169
  }
148
170
 
149
171
  // ---------------------------------------------------------------------------
150
- // 2. Tel: click tracking via sendBeacon
172
+ // 2. Tel:/mailto: click tracking via sendBeacon, plus a Google Ads conversion
151
173
  // ---------------------------------------------------------------------------
152
174
 
175
+ /**
176
+ * Typed locally, matching `ConsentLike` above: a global augmentation here
177
+ * would widen `Window` package-wide for a shape only this file uses.
178
+ *
179
+ * `gtag` is a plain function global — it's declared inside the classic
180
+ * (non-module) inline `<script>` `gtagBootstrap` emits (`@getvitops/utils/ads`),
181
+ * which attaches it to `window`. This file runs as a module (`<Tracking />`
182
+ * imports it), so it must go through `window.gtag`, not a bare `gtag(...)` call.
183
+ * `vitopsAdsConversions.google` is the same bootstrap's per-action `send_to`
184
+ * map (`{call?, email?, form?}`) — each action carries its own target, so a
185
+ * call, an email click and a form submission report as distinct Google Ads
186
+ * conversion actions instead of all three sharing one.
187
+ */
188
+ interface AdsGlobal {
189
+ gtag?: (...args: unknown[]) => void;
190
+ vitopsAdsConversions?: Record<string, Partial<Record<'call' | 'email' | 'form', string>>>;
191
+ }
192
+ const adsGlobal = (): AdsGlobal => window as unknown as AdsGlobal;
193
+
153
194
  document.addEventListener('click', (e) => {
154
- const link = (e.target as Element).closest?.('a[href^="tel:"]') as HTMLAnchorElement | null;
195
+ const link = (e.target as Element).closest?.(
196
+ 'a[href^="tel:"], a[href^="mailto:"]',
197
+ ) as HTMLAnchorElement | null;
155
198
  if (!link) return;
156
199
 
157
- // Fire-and-forget beacon; don't block the tel: navigation
200
+ const isEmail = link.href.startsWith('mailto:');
201
+
202
+ // Fire-and-forget beacon; don't block the tel:/mailto: navigation.
158
203
  if (navigator.sendBeacon) {
159
- navigator.sendBeacon(TRACK_ENDPOINT, JSON.stringify({ event: 'call', phone: link.href }));
204
+ const body = isEmail
205
+ ? { event: 'email', email: link.href }
206
+ : { event: 'call', phone: link.href };
207
+ navigator.sendBeacon(TRACK_ENDPOINT, JSON.stringify(body));
208
+ }
209
+
210
+ // Report the same click as a Google Ads conversion, when that action's
211
+ // `conversionLabel` is configured. `sendTo` is absent whenever the tag never
212
+ // loaded (gated and undecided, or `site.ads.google` unset) or that specific
213
+ // action has no label — silently skipped, same as every other
214
+ // optional-runtime check in this file.
215
+ const sendTo = adsGlobal().vitopsAdsConversions?.['google']?.[isEmail ? 'email' : 'call'];
216
+ if (sendTo && typeof adsGlobal().gtag === 'function') {
217
+ adsGlobal().gtag!('event', 'conversion', { send_to: sendTo });
160
218
  }
161
219
  });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // 3. Form-submission conversion, for a consumer's own fetch-based submit handler
223
+ // ---------------------------------------------------------------------------
224
+
225
+ const CONVERTED_ATTR = 'data-vitops-converted';
226
+
227
+ /**
228
+ * Fires the Google Ads `form` conversion action (see `AdsGlobal` above) for a
229
+ * consumer's own fetch-based submit handler to call once its own POST to
230
+ * `createConversionRoute()` has succeeded — mirroring the tel:/mailto: click
231
+ * handler above, but for a form, which has no click to hook.
232
+ *
233
+ * Idempotent per element (`data-vitops-converted`): a handler that fires this
234
+ * more than once for the same submission (a retried network call, a
235
+ * double-invoked effect) reports the conversion once, not twice.
236
+ *
237
+ * There is deliberately no server-side equivalent for a no-JS
238
+ * `<form method="post">` submission — that path is a full-page redirect with no
239
+ * script execution to fire from, and reporting a Google-Ads-labelled
240
+ * conversion from the server needs the Google Ads API's click-conversion
241
+ * upload (developer token + MCC access), not a client-side pixel call. See the
242
+ * vNext backlog's §16 for that follow-up.
243
+ */
244
+ function vitopsFormConversion(formEl: HTMLFormElement): void {
245
+ if (formEl.hasAttribute(CONVERTED_ATTR)) return;
246
+ formEl.setAttribute(CONVERTED_ATTR, '');
247
+ const sendTo = adsGlobal().vitopsAdsConversions?.['google']?.['form'];
248
+ if (sendTo && typeof adsGlobal().gtag === 'function') {
249
+ adsGlobal().gtag!('event', 'conversion', { send_to: sendTo });
250
+ }
251
+ }
252
+
253
+ (window as unknown as { vitopsFormConversion?: typeof vitopsFormConversion }).vitopsFormConversion =
254
+ vitopsFormConversion;
package/src/tracking.ts CHANGED
@@ -28,7 +28,7 @@ export interface GetvitopsTrackingOptions {
28
28
  export const TRACKING_COOKIE = '_ac';
29
29
 
30
30
  /**
31
- * Where the capture script sends its `tel:` conversion beacon.
31
+ * Where the capture script sends its `tel:`/`mailto:` conversion beacons.
32
32
  *
33
33
  * The route itself is the consumer's to write (`createConversionRoute()` in
34
34
  * `@getvitops/astro/routes`), so this is the one place the two halves agree on
package/virtual.d.ts CHANGED
@@ -11,6 +11,17 @@ declare module 'virtual:getvitops/head' {
11
11
  cssVariable: string;
12
12
  preload: boolean | { weight?: string | number; style?: string }[];
13
13
  }
14
+ /**
15
+ * Mirrors `HeadTrackingData` in src/integration.ts. Its own named interface
16
+ * (not inlined into `HeadData` below) so the drift guard's line-by-line key
17
+ * regex doesn't pick up its fields as false top-level `HeadData` matches.
18
+ */
19
+ interface HeadTrackingData {
20
+ enabled: boolean;
21
+ category: string;
22
+ cookies: string[];
23
+ abVariantCookie: string | null;
24
+ }
14
25
  // Keep these keys in step with `HeadData` in src/integration.ts — the two are
15
26
  // hand-duplicated (an ambient `declare module` in a shipped .d.ts cannot import
16
27
  // from src), and `head.test.ts` asserts the key sets match.
@@ -40,7 +51,12 @@ declare module 'virtual:getvitops/head' {
40
51
  // integration rather than the component because its warnings belong beside
41
52
  // the others, and the offered consent categories it checks against are known
42
53
  // only there.
43
- tracking: { enabled: boolean; category: string; cookies: string[] } | null;
54
+ tracking: HeadTrackingData | null;
55
+ // Enabled `site.experiments`, keyed the same way, resolved to their cookie
56
+ // names — re-typed by <Analytics /> on the way into `resolveAnalytics`.
57
+ experiments: Record<string, { cookieName: string }>;
58
+ // `site.analytics.matomo.abVariantDimensions`, passed through unchanged.
59
+ matomoAbVariantDimensions: Record<string, number>;
44
60
  // The site config's `site.ads` block plus this environment's switch —
45
61
  // structurally `{ enabled, properties }` (src/ads.ts), re-typed by <Ads /> on
46
62
  // the way into `resolveAds`. Read in the integration because that is where the
@@ -1 +0,0 @@
1
- {"version":3,"file":"tracking-CmSfxWC3.mjs","names":[],"sources":["../src/tracking.ts"],"sourcesContent":["/**\n * The `<Tracking />` resolution layer — tracking config in, what to emit out.\n *\n * Separate from `Tracking.astro` for the same reason `analytics.ts` is separate\n * from `Analytics.astro`: everything decidable is a pure function asserted in\n * `tracking.test.ts`, and the component only renders what this returns.\n *\n * The one decision here is **which consent category the `_ac` cookie waits on**,\n * and it is stated rather than inferred. `_ac` is a 90-day identifier tying a\n * visitor to the ad that brought them, so `marketing` is the default — but a site\n * using click IDs purely for its own campaign reporting may reasonably file it\n * under `analytics`, and that is the consumer's call to make, not ours to guess.\n */\n\n/** Which consent category the attribution cookie waits on. */\nexport type TrackingCategory = 'marketing' | 'analytics';\n\nexport interface GetvitopsTrackingOptions {\n /** Capture ad click IDs and UTMs into `_ac`. */\n enabled?: boolean;\n /** Default `marketing`. */\n category?: TrackingCategory;\n /** Informational; capture recognises every known click-ID parameter regardless. */\n platforms?: string[];\n}\n\n/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */\nexport const TRACKING_COOKIE = '_ac';\n\n/**\n * Where the capture script sends its `tel:` conversion beacon.\n *\n * The route itself is the consumer's to write (`createConversionRoute()` in\n * `@getvitops/astro/routes`), so this is the one place the two halves agree on\n * a path. It was a bare literal inside the inlined script: a consumer who named\n * the file anything else got a silent 404 on every beacon and lost conversions\n * with no error anywhere to explain it. Exported so a consumer can assert\n * against it, mirrored in `scripts/tracking.ts` because that file is inlined\n * into the document and cannot import, and pinned by `tracking.test.ts`.\n */\nexport const TRACKING_ENDPOINT = '/api/track';\n\nexport interface ResolvedTracking {\n enabled: boolean;\n category: TrackingCategory;\n /** Cookie names to clear on revoke — rides on `data-consent-cookies`. */\n cookies: string[];\n /** Configuration problems, as prose. The integration logs these. */\n warnings: string[];\n}\n\nexport interface ResolveTrackingContext {\n /** Is the consent gate enabled on this site (`vitops({ consent })`)? */\n consent?: boolean;\n /** Categories the banner offers, so we can catch a demand with no row. */\n consentCategories?: readonly string[];\n}\n\n/**\n * Resolve the tracking config into what the component emits.\n *\n * Two warnings, both about the same failure — a persistent identifier written\n * with no way for the visitor to decline, or a permission asked for with no way\n * to answer. Returned as data rather than logged here so the check is a test\n * rather than a line someone might scroll past, exactly as `resolveAnalytics`\n * does.\n */\nexport function resolveTracking(\n opts: GetvitopsTrackingOptions | undefined,\n ctx: ResolveTrackingContext = {},\n): ResolvedTracking {\n const warnings: string[] = [];\n const category: TrackingCategory = opts?.category ?? 'marketing';\n const enabled = opts?.enabled === true;\n\n if (!enabled) return { enabled: false, category, cookies: [], warnings };\n\n if (!ctx.consent) {\n warnings.push(\n `tracking is on, so this site writes the \\`${TRACKING_COOKIE}\\` cookie — a 90-day ` +\n 'identifier tying a visitor to the ad that brought them — and `consent` is off, so it ' +\n 'is written for every visitor with no way to decline. Set `consent: true`.',\n );\n } else if (ctx.consentCategories && !ctx.consentCategories.includes(category)) {\n // The banner would be raised for a category it renders no row for: the\n // visitor is asked a question the form does not show them.\n warnings.push(\n `tracking demands the \\`${category}\\` consent category, but the banner offers ` +\n `${ctx.consentCategories.join(', ') || 'nothing'}. Add \\`${category}\\` to ` +\n '`consent.categories`, or the visitor is asked about something the banner never names.',\n );\n }\n\n return { enabled, category, cookies: [TRACKING_COOKIE], warnings };\n}\n"],"mappings":";;AA2BA,MAAa,kBAAkB;;;;;;;;;;;;AAa/B,MAAa,oBAAoB;;;;;;;;;;AA2BjC,SAAgB,gBACd,MACA,MAA8B,CAAC,GACb;CAClB,MAAM,WAAqB,CAAC;CAC5B,MAAM,WAA6B,MAAM,YAAY;CACrD,MAAM,UAAU,MAAM,YAAY;CAElC,IAAI,CAAC,SAAS,OAAO;EAAE,SAAS;EAAO;EAAU,SAAS,CAAC;EAAG;CAAS;CAEvE,IAAI,CAAC,IAAI,SACP,SAAS,KACP,mDAA6D,wLAG/D;MACK,IAAI,IAAI,qBAAqB,CAAC,IAAI,kBAAkB,SAAS,QAAQ,GAG1E,SAAS,KACP,0BAA0B,SAAS,6CAC9B,IAAI,kBAAkB,KAAK,IAAI,KAAK,UAAU,UAAU,SAAS,8FAExE;CAGF,OAAO;EAAE;EAAS;EAAU,SAAS,MAAgB;EAAG;CAAS;AACnE"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"tracking-DdEKExlE.d.mts","names":[],"sources":["../src/tracking.ts"],"mappings":";;AAeA;;;;AAA4B;AAE5B;;;;;;;;KAFY,gBAAA;AAAA,UAEK,wBAAA;EAUJ;EARX,OAAA;;EAEA,QAAA,GAAW,gBAAgB;EAMD;EAJ1B,SAAA;AAAA;;cAIW,eAAA;AAaiB;AAE9B;;;;;;;;;;AAF8B,cAAjB,iBAAA;AAAA,UAEI,gBAAA;EACf,OAAA;EACA,QAAA,EAAU,gBAAgB;EAS1B;EAPA,OAAA;EAqBc;EAnBd,QAAA;AAAA;AAAA,UAGe,sBAAA;EAkBV;EAhBL,OAAA;EAiBiB;EAfjB,iBAAiB;AAAA;;;;;;AAeA;;;;iBAHH,eAAA,CACd,IAAA,EAAM,wBAAA,cACN,GAAA,GAAK,sBAAA,GACJ,gBAAA"}