@getvitops/astro 3.0.0 → 4.0.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.
@@ -0,0 +1,147 @@
1
+ // Ad conversion tracking: capture click IDs from the landing URL, track tel:
2
+ // clicks. Form enhancement is in form-enhance.ts.
3
+ //
4
+ // Runs early and ungated on purpose. Reading the query string is not storage and
5
+ // needs nobody's permission; *keeping* it does. So the capture is synchronous —
6
+ // it has to be, the click ID is only in the URL on the landing page — and only
7
+ // the write to `_ac` waits on consent.
8
+ //
9
+ // The `_ac` cookie is a 90-day identifier tying a visitor to the ad that brought
10
+ // them, which is what `marketing` covers. Two things follow, and both matter:
11
+ //
12
+ // - It **demands** the category rather than passively reading it. Nothing else
13
+ // on a page would ask for `marketing`, so a passive `granted()` check is a
14
+ // permanent no-op: the banner never offers it, so it is never granted, so
15
+ // `_ac` is never written and no attribution ever reaches a notification —
16
+ // silently, on every site that enabled the gate.
17
+ // - It only demands when the URL actually carried something. A visitor arriving
18
+ // organically has nothing to attribute, so they are never asked. That is the
19
+ // whole point of a demand-driven banner applied to attribution: the one
20
+ // arrival that needs the permission is the one that requests it.
21
+ //
22
+ // With no gate on the page it behaves as it always has — a site that has not
23
+ // adopted consent has made no promise for this to break.
24
+
25
+ const COOKIE_NAME = '_ac';
26
+ const COOKIE_DAYS = 90;
27
+ const AB_COOKIE = '_ab';
28
+
29
+ // Mirrors CLICK_ID_PARAMS/UTM_PARAMS in @getvitops/utils/tracking, deliberately
30
+ // copied rather than imported: this file is inlined into the document by
31
+ // <Tracking />, so a bare import would either fail or drag a module graph into
32
+ // the critical path. `tracking.test.ts` in this package pins the two lists.
33
+ const CLICK_ID_PARAMS = [
34
+ 'gclid',
35
+ 'gbraid',
36
+ 'wbraid', // Google Ads
37
+ 'fbclid', // Meta (Facebook/Instagram)
38
+ 'ttclid', // TikTok
39
+ 'rdt_cid', // Reddit
40
+ 'ScCid', // Snapchat
41
+ 'msclkid', // Microsoft/Bing
42
+ ] as const;
43
+
44
+ const UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const;
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // 1. Capture click IDs + UTMs from URL → cookie
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const params = new URLSearchParams(location.search);
51
+ const trackingData: Record<string, string> = {};
52
+
53
+ for (const key of [...CLICK_ID_PARAMS, ...UTM_PARAMS]) {
54
+ const value = params.get(key);
55
+ if (value) trackingData[key] = value;
56
+ }
57
+
58
+ // Include A/B variant if assigned by the router Worker. A cookie present but
59
+ // valueless yields undefined, which must not be written into the record.
60
+ const abMatch = document.cookie.split(';').find((c) => c.trim().startsWith(`${AB_COOKIE}=`));
61
+ const abVariant = abMatch?.split('=')[1]?.trim();
62
+ if (abVariant) trackingData['ab_variant'] = abVariant;
63
+
64
+ function readCookie(): Record<string, unknown> | null {
65
+ const match = document.cookie.split(';').find((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
66
+ if (!match) return null;
67
+ try {
68
+ const data: unknown = JSON.parse(decodeURIComponent(match.split('=').slice(1).join('=')));
69
+ return typeof data === 'object' && data !== null && !Array.isArray(data)
70
+ ? (data as Record<string, unknown>)
71
+ : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function persist(): void {
78
+ // Merge with any existing cookie data (don't overwrite prior click IDs from a
79
+ // different session unless new ones are present). `ts` keeps the FIRST
80
+ // capture: the 90-day window is measured from the original click, and taking
81
+ // the latest would restart it on every visit.
82
+ const existing = readCookie();
83
+ const merged = {
84
+ ...existing,
85
+ ...trackingData,
86
+ landingPage: location.pathname,
87
+ referrer: document.referrer || undefined,
88
+ ts: existing?.['ts'] ?? Date.now(),
89
+ };
90
+
91
+ // Remove undefined values before serializing
92
+ const clean = Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== undefined));
93
+
94
+ const expires = new Date(Date.now() + COOKIE_DAYS * 864e5).toUTCString();
95
+ const secure = location.protocol === 'https:' ? ';Secure' : '';
96
+ document.cookie = `${COOKIE_NAME}=${encodeURIComponent(
97
+ JSON.stringify(clean),
98
+ )};expires=${expires};path=/;SameSite=Lax${secure}`;
99
+ }
100
+
101
+ /** Typed locally: a global augmentation here would widen `Window` package-wide. */
102
+ interface ConsentLike {
103
+ granted(c: string): boolean;
104
+ require(c: string): boolean;
105
+ }
106
+ const gate = (): ConsentLike | undefined =>
107
+ (window as unknown as { vitopsConsent?: ConsentLike }).vitopsConsent;
108
+
109
+ if (Object.keys(trackingData).length > 0) {
110
+ // <Tracking /> writes the category onto this marker, which also carries
111
+ // `data-consent-cookies` so revoking clears `_ac`.
112
+ const marker = document.querySelector('[data-vitops-tracking]');
113
+ const category = marker?.getAttribute('data-consent') ?? 'marketing';
114
+
115
+ if (!gate()) {
116
+ // No gate on this site: behave as it always has.
117
+ persist();
118
+ } else {
119
+ // Ask. This is what raises the banner — without it nothing on the page ever
120
+ // demands `marketing`, so it is never granted and `_ac` is never written.
121
+ if (gate()?.require(category)) persist();
122
+
123
+ // Then keep listening. A standing listener rather than acting on the one-shot
124
+ // answer above, for two reasons that both bite: `window.vitopsConsent` may
125
+ // still be the inline stub <Head /> emits — which queues `require()` and has
126
+ // no `subscribe` at all — and the visitor may accept a moment later, in this
127
+ // same page view, with the click ID still in the URL. The event fires either
128
+ // way, and the runtime publishes once on startup after draining the stub.
129
+ document.addEventListener('vitops:consent', () => {
130
+ if (gate()?.granted(category)) persist();
131
+ });
132
+ }
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // 2. Tel: click tracking via sendBeacon
137
+ // ---------------------------------------------------------------------------
138
+
139
+ document.addEventListener('click', (e) => {
140
+ const link = (e.target as Element).closest?.('a[href^="tel:"]') as HTMLAnchorElement | null;
141
+ if (!link) return;
142
+
143
+ // Fire-and-forget beacon; don't block the tel: navigation
144
+ if (navigator.sendBeacon) {
145
+ navigator.sendBeacon('/api/track', JSON.stringify({ event: 'call', phone: link.href }));
146
+ }
147
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The `<Tracking />` resolution layer — tracking config in, what to emit out.
3
+ *
4
+ * Separate from `Tracking.astro` for the same reason `analytics.ts` is separate
5
+ * from `Analytics.astro`: everything decidable is a pure function asserted in
6
+ * `tracking.test.ts`, and the component only renders what this returns.
7
+ *
8
+ * The one decision here is **which consent category the `_ac` cookie waits on**,
9
+ * and it is stated rather than inferred. `_ac` is a 90-day identifier tying a
10
+ * visitor to the ad that brought them, so `marketing` is the default — but a site
11
+ * using click IDs purely for its own campaign reporting may reasonably file it
12
+ * under `analytics`, and that is the consumer's call to make, not ours to guess.
13
+ */
14
+
15
+ /** Which consent category the attribution cookie waits on. */
16
+ export type TrackingCategory = 'marketing' | 'analytics';
17
+
18
+ export interface GetvitopsTrackingOptions {
19
+ /** Capture ad click IDs and UTMs into `_ac`. */
20
+ enabled?: boolean;
21
+ /** Default `marketing`. */
22
+ category?: TrackingCategory;
23
+ /** Informational; capture recognises every known click-ID parameter regardless. */
24
+ platforms?: string[];
25
+ }
26
+
27
+ /** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
28
+ export const TRACKING_COOKIE = '_ac';
29
+
30
+ export interface ResolvedTracking {
31
+ enabled: boolean;
32
+ category: TrackingCategory;
33
+ /** Cookie names to clear on revoke — rides on `data-consent-cookies`. */
34
+ cookies: string[];
35
+ /** Configuration problems, as prose. The integration logs these. */
36
+ warnings: string[];
37
+ }
38
+
39
+ export interface ResolveTrackingContext {
40
+ /** Is the consent gate enabled on this site (`vitops({ consent })`)? */
41
+ consent?: boolean;
42
+ /** Categories the banner offers, so we can catch a demand with no row. */
43
+ consentCategories?: readonly string[];
44
+ }
45
+
46
+ /**
47
+ * Resolve the tracking config into what the component emits.
48
+ *
49
+ * Two warnings, both about the same failure — a persistent identifier written
50
+ * with no way for the visitor to decline, or a permission asked for with no way
51
+ * to answer. Returned as data rather than logged here so the check is a test
52
+ * rather than a line someone might scroll past, exactly as `resolveAnalytics`
53
+ * does.
54
+ */
55
+ export function resolveTracking(
56
+ opts: GetvitopsTrackingOptions | undefined,
57
+ ctx: ResolveTrackingContext = {},
58
+ ): ResolvedTracking {
59
+ const warnings: string[] = [];
60
+ const category: TrackingCategory = opts?.category ?? 'marketing';
61
+ const enabled = opts?.enabled === true;
62
+
63
+ if (!enabled) return { enabled: false, category, cookies: [], warnings };
64
+
65
+ if (!ctx.consent) {
66
+ warnings.push(
67
+ `tracking is on, so this site writes the \`${TRACKING_COOKIE}\` cookie — a 90-day ` +
68
+ 'identifier tying a visitor to the ad that brought them — and `consent` is off, so it ' +
69
+ 'is written for every visitor with no way to decline. Set `consent: true`.',
70
+ );
71
+ } else if (ctx.consentCategories && !ctx.consentCategories.includes(category)) {
72
+ // The banner would be raised for a category it renders no row for: the
73
+ // visitor is asked a question the form does not show them.
74
+ warnings.push(
75
+ `tracking demands the \`${category}\` consent category, but the banner offers ` +
76
+ `${ctx.consentCategories.join(', ') || 'nothing'}. Add \`${category}\` to ` +
77
+ '`consent.categories`, or the visitor is asked about something the banner never names.',
78
+ );
79
+ }
80
+
81
+ return { enabled, category, cookies: [TRACKING_COOKIE], warnings };
82
+ }
package/virtual.d.ts CHANGED
@@ -47,6 +47,11 @@ declare module 'virtual:getvitops/head' {
47
47
  consentCategories: string[];
48
48
  consentPolicyUrl: string | null;
49
49
  consentRuntime: boolean;
50
+ // Structurally `ResolvedTracking` (src/tracking.ts) — resolved in the
51
+ // integration rather than the component because its warnings belong beside
52
+ // the others, and the offered consent categories it checks against are known
53
+ // only there.
54
+ tracking: { enabled: boolean; category: string; cookies: string[] } | null;
50
55
  }
51
56
  const data: HeadData;
52
57
  export default data;