@getvitops/astro 8.3.0-alpha.4 → 8.3.0-alpha.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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Pure decisions behind `<Masthead />` and the `<SiteNav />` options: merge `site.chrome` with a
3
+ * per-layout override, and turn the result into classes, custom properties and `SiteNav` props.
4
+ * Kept out of the `.astro` file so it is asserted without rendering (the `sitenav.ts` split). It
5
+ * imports the schema's literal lists as values from `@getvitops/config` (a zod-free-at-runtime
6
+ * leaf for these constants) and nothing from the generator, so it cannot drag it into a Worker bundle.
7
+ */
8
+ import {
9
+ MASTHEAD_BEHAVIORS,
10
+ MASTHEAD_LAYOUTS,
11
+ MASTHEAD_POSITIONS,
12
+ MASTHEAD_SURFACES,
13
+ MASTHEAD_WIDTHS,
14
+ NAV_BREAKPOINTS,
15
+ NAV_COMPACT_MODES,
16
+ NAV_DRAWER_SIDES,
17
+ type ChromeNavConfig,
18
+ type MastheadBehavior,
19
+ type MastheadConfig,
20
+ type MastheadLayout,
21
+ type MastheadPosition,
22
+ type MastheadSurface,
23
+ type MastheadWidth,
24
+ } from '@getvitops/config';
25
+ import type { NavBreakpoint, NavCompactMode, NavDrawerSide } from './sitenav.ts';
26
+
27
+ /** A masthead override: any `site.chrome.masthead` field, plus `elevation` merged key-by-key. */
28
+ export type MastheadOptions = MastheadConfig;
29
+
30
+ export interface ResolvedMasthead {
31
+ position: MastheadPosition;
32
+ behavior: MastheadBehavior;
33
+ surface: MastheadSurface;
34
+ tone: string | undefined;
35
+ layout: MastheadLayout;
36
+ width: MastheadWidth;
37
+ shadow: string | undefined;
38
+ border: boolean;
39
+ elevateOnScroll: boolean;
40
+ height: string | undefined;
41
+ inset: string | undefined;
42
+ }
43
+
44
+ /**
45
+ * What `resolveMasthead` needs from a config — a whole `Config`, or just `{ site: { chrome } }`.
46
+ * Deliberately loose: a plain `import site from './site.json'` types every enum as `string`, which
47
+ * the strict `ChromeConfig` would reject (the same trap `resolveOrganizationName` fell into). The
48
+ * values are checked at runtime instead, with the names of the allowed ones in the error.
49
+ */
50
+ export interface ChromeSource {
51
+ site?: { chrome?: { masthead?: Record<string, any>; nav?: Record<string, any> } };
52
+ }
53
+
54
+ function oneOf<T extends string>(
55
+ value: unknown,
56
+ allowed: readonly T[],
57
+ field: string,
58
+ ): T | undefined {
59
+ if (value === undefined) return undefined;
60
+ if (!allowed.includes(value as T))
61
+ throw new Error(
62
+ `site.chrome ${field}: "${String(value)}" is not one of ${allowed.join(' | ')}.`,
63
+ );
64
+ return value as T;
65
+ }
66
+
67
+ const LENGTH_FUNCTIONS = new Set(['calc', 'clamp', 'min', 'max']);
68
+ const LENGTH_UNITS = new Set([
69
+ 'px',
70
+ 'rem',
71
+ 'em',
72
+ 'ch',
73
+ '%',
74
+ 'vw',
75
+ 'vh',
76
+ 'vi',
77
+ 'vb',
78
+ 'svw',
79
+ 'svh',
80
+ 'dvw',
81
+ 'dvh',
82
+ 'lvw',
83
+ 'lvh',
84
+ 'cqw',
85
+ 'cqh',
86
+ 'cqi',
87
+ 'cqb',
88
+ ]);
89
+
90
+ /**
91
+ * A CSS length written into a `<style>` — so a closed grammar, not a blocklist. Numbers must carry
92
+ * a unit from `LENGTH_UNITS` (or be a bare `0`), functions are limited to `calc|clamp|min|max`,
93
+ * parentheses must balance, and nothing else is admitted: no `var()`, `url()`, `;`, braces or quotes.
94
+ * `4rem`, `clamp(3.5rem, 8cqi, 4.5rem)` and `min(100%, 80rem)` pass; `4rem;}body{…` does not.
95
+ */
96
+ export function isSafeLength(value: string): boolean {
97
+ if (!/^[0-9a-z%.,+\-*/() ]+$/.test(value)) return false;
98
+ let depth = 0;
99
+ for (const ch of value) {
100
+ if (ch === '(') depth++;
101
+ else if (ch === ')' && --depth < 0) return false;
102
+ }
103
+ if (depth !== 0) return false;
104
+ // Every identifier directly before `(` must be an allowed function.
105
+ for (const [, name] of value.matchAll(/([a-z]+)\(/g))
106
+ if (!LENGTH_FUNCTIONS.has(name!)) return false;
107
+ // Strip the function names, then every remaining word must be a number+unit or a lone operator.
108
+ const rest = value.replace(/[a-z]+\(/g, '(');
109
+ const tokens = rest.split(/[\s,()+*/]+|(?<=\d)-(?=\d)|\s-\s/).filter(Boolean);
110
+ if (tokens.length === 0) return false;
111
+ return tokens.every((t) => {
112
+ if (t === '0' || t === '-') return true;
113
+ const m = /^-?(?:\d+\.?\d*|\.\d+)([a-z%]*)$/.exec(t);
114
+ return m !== null && m[1] !== undefined && LENGTH_UNITS.has(m[1]);
115
+ });
116
+ }
117
+ const ROLE = /^[a-z][a-z0-9-]*$/;
118
+ const SHADOW = /^[a-z0-9][a-z0-9-]*$/;
119
+
120
+ export function resolveMasthead(
121
+ config: ChromeSource | undefined,
122
+ override: MastheadOptions = {},
123
+ ): ResolvedMasthead {
124
+ const base: MastheadConfig = config?.site?.chrome?.masthead ?? {};
125
+ const pick = <K extends keyof MastheadConfig>(k: K): MastheadConfig[K] =>
126
+ override[k] !== undefined ? override[k] : base[k];
127
+ const elevation = { ...base.elevation, ...override.elevation };
128
+
129
+ const position = oneOf(pick('position'), MASTHEAD_POSITIONS, 'masthead.position') ?? 'static';
130
+ const height = pick('height');
131
+ if (height !== undefined && !isSafeLength(height))
132
+ throw new Error(
133
+ `Masthead: height "${height}" is not a plain CSS length (e.g. "4rem" or "clamp(3.5rem, 8cqi, 4.5rem)").`,
134
+ );
135
+ const inset = pick('inset');
136
+ if (inset !== undefined && !isSafeLength(inset))
137
+ throw new Error(
138
+ `Masthead: inset "${inset}" is not a plain CSS length (e.g. "0.75rem" or "clamp(0.5rem, 2cqi, 1.5rem)").`,
139
+ );
140
+ const tone = pick('tone');
141
+ if (tone !== undefined && !ROLE.test(tone.replace(/:solid$/, '')))
142
+ throw new Error(`Masthead: tone "${tone}" must be a role name, optionally "<role>:solid".`);
143
+ if (elevation.shadow !== undefined && !SHADOW.test(elevation.shadow))
144
+ throw new Error(`Masthead: elevation.shadow "${elevation.shadow}" is not a shadow name.`);
145
+
146
+ return {
147
+ position,
148
+ // Nothing to hide when the bar scrolls away with the page anyway.
149
+ behavior:
150
+ position === 'static'
151
+ ? 'always'
152
+ : (oneOf(pick('behavior'), MASTHEAD_BEHAVIORS, 'masthead.behavior') ?? 'always'),
153
+ surface: oneOf(pick('surface'), MASTHEAD_SURFACES, 'masthead.surface') ?? 'solid',
154
+ tone,
155
+ layout: oneOf(pick('layout'), MASTHEAD_LAYOUTS, 'masthead.layout') ?? 'full',
156
+ width: oneOf(pick('width'), MASTHEAD_WIDTHS, 'masthead.width') ?? 'content',
157
+ shadow: elevation.shadow,
158
+ border: elevation.border ?? false,
159
+ elevateOnScroll: elevation.onScroll ?? false,
160
+ height,
161
+ inset,
162
+ };
163
+ }
164
+
165
+ /** Modifier classes on `.masthead`. */
166
+ export function mastheadClass(m: ResolvedMasthead): string[] {
167
+ const scrollOnly = m.elevateOnScroll;
168
+ return [
169
+ 'masthead',
170
+ ...(m.position !== 'static' ? [`masthead--${m.position}`] : []),
171
+ ...(m.behavior === 'hide-on-scroll' ? ['masthead--hide-on-scroll'] : []),
172
+ m.surface !== 'solid' ? `masthead--${m.surface}` : '',
173
+ m.layout !== 'full' ? `masthead--${m.layout}` : '',
174
+ ...(m.shadow
175
+ ? ['masthead--shadow', ...(scrollOnly ? ['masthead--shadow-on-scroll'] : [])]
176
+ : []),
177
+ ...(m.border
178
+ ? ['masthead--border', ...(scrollOnly ? ['masthead--border-on-scroll'] : [])]
179
+ : []),
180
+ ...(m.tone ? ['tone'] : []),
181
+ ].filter(Boolean);
182
+ }
183
+
184
+ /**
185
+ * The `.tone` custom properties for a role, as a `style` string. Each is a `var()` chain that
186
+ * degrades to the surface role, so a role the site never defined is a neutral bar, not a
187
+ * transparent one. `<role>:solid` fills with the role's solid and its computed foreground.
188
+ */
189
+ export function mastheadStyle(m: ResolvedMasthead): string | undefined {
190
+ const parts: string[] = [];
191
+ if (m.tone) {
192
+ const solid = m.tone.endsWith(':solid');
193
+ const r = m.tone.replace(/:solid$/, '');
194
+ parts.push(
195
+ solid
196
+ ? `--tone-bg: var(--color-bg-${r}-solid, var(--color-bg-${r}, var(--color-bg-surface)))`
197
+ : `--tone-bg: var(--color-bg-${r}, var(--color-bg-${r}-muted, var(--color-bg-surface)))`,
198
+ solid
199
+ ? `--tone-fg: var(--color-text-on-${r}, var(--color-text-surface))`
200
+ : `--tone-fg: var(--color-text-${r}, var(--color-text-surface))`,
201
+ `--tone-border: var(--color-border-${r}, var(--color-border-surface))`,
202
+ );
203
+ }
204
+ if (m.shadow) parts.push(`--masthead-shadow: var(--shadow-${m.shadow})`);
205
+ return parts.length ? parts.join('; ') : undefined;
206
+ }
207
+
208
+ /**
209
+ * Page-level rule for a custom height and inset — `--header-offset` reads both from `:root`, and
210
+ * the vars inherit down to the masthead. The inset is written only for a layout that has one (a
211
+ * full-width bar sits flush) and then always, defaulted to the same `0.75rem` the element falls
212
+ * back to, since `--header-offset` adds `--masthead-inset` unconditionally.
213
+ */
214
+ export function mastheadRootCss(m: ResolvedMasthead): string | undefined {
215
+ const decls = [
216
+ m.height ? `--masthead-height:${m.height}` : '',
217
+ // Always written for an inset layout, defaulted, so `--header-offset` and the element agree.
218
+ m.layout !== 'full' ? `--masthead-inset:${m.inset ?? '0.75rem'}` : '',
219
+ ].filter(Boolean);
220
+ return decls.length ? `:root{${decls.join(';')}}` : undefined;
221
+ }
222
+
223
+ /** `<SiteNav>` props from `site.chrome.nav`, with an optional override. */
224
+ export interface ResolvedNavOptions {
225
+ breakpoint?: NavBreakpoint;
226
+ mobileBreakpoint?: NavBreakpoint;
227
+ megaBreakpoint?: NavBreakpoint;
228
+ depth?: number;
229
+ megaDepth?: number;
230
+ compact?: NavCompactMode;
231
+ drawerSide?: NavDrawerSide;
232
+ }
233
+
234
+ export function resolveNavOptions(
235
+ config: ChromeSource | undefined,
236
+ override: ChromeNavConfig = {},
237
+ ): ResolvedNavOptions {
238
+ const m = stripUndefined({ ...config?.site?.chrome?.nav, ...stripUndefined(override) });
239
+ return stripUndefined({
240
+ breakpoint: oneOf(m.breakpoint, NAV_BREAKPOINTS, 'nav.breakpoint'),
241
+ mobileBreakpoint: oneOf(m.mobileBreakpoint, NAV_BREAKPOINTS, 'nav.mobileBreakpoint'),
242
+ megaBreakpoint: oneOf(m.megaBreakpoint, NAV_BREAKPOINTS, 'nav.megaBreakpoint'),
243
+ depth: m.depth,
244
+ megaDepth: m.megaDepth,
245
+ compact: oneOf(m.compact, NAV_COMPACT_MODES, 'nav.compact'),
246
+ drawerSide: oneOf(m.drawerSide, NAV_DRAWER_SIDES, 'nav.drawerSide'),
247
+ }) as ResolvedNavOptions;
248
+ }
249
+
250
+ function stripUndefined<T extends object>(o: T): T {
251
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
252
+ }
@@ -126,6 +126,10 @@ export function containsCurrent(item: NavItem, currentPath: string): boolean {
126
126
  );
127
127
  }
128
128
 
129
+ /** What the toggle opens below a nav's breakpoint. */
130
+ export type NavCompactMode = 'drawer' | 'dropdown';
131
+ export type NavDrawerSide = 'start' | 'end';
132
+
129
133
  export interface SiteNavTier {
130
134
  kind: 'mobile' | 'default' | 'mega';
131
135
  /** Menu name this tier is read from. */
@@ -257,16 +261,48 @@ export function tierClass(tier: SiteNavTier): string[] {
257
261
  ];
258
262
  }
259
263
 
260
- /** Extra classes on the `.sitenav` root for a tier. */
261
- export function navClass(tier: SiteNavTier): string[] {
264
+ export interface NavClassOptions {
265
+ /** `drawer` (default): an edge drawer. `dropdown`: a columnar panel anchored under the toggle. */
266
+ compact?: NavCompactMode;
267
+ /** Edge the drawer slides from. Default `end`. Ignored for `dropdown`. */
268
+ drawerSide?: NavDrawerSide;
269
+ }
270
+
271
+ /**
272
+ * Extra classes on the `.sitenav` root for a tier. A dropdown panel carries NO `drawer-*` class on
273
+ * purpose: those are narrow-state rules at the same specificity as the dropdown's, so emitting both
274
+ * would leave the winner to source order.
275
+ */
276
+ export function navClass(tier: SiteNavTier, opts: NavClassOptions = {}): string[] {
277
+ const compact = opts.compact ?? 'drawer';
262
278
  return [
263
279
  'sitenav',
264
280
  `sitenav--bp-${tier.breakpoint}`,
265
- 'sitenav--drawer-end',
281
+ compact === 'dropdown'
282
+ ? 'sitenav--panel-dropdown'
283
+ : `sitenav--drawer-${opts.drawerSide ?? 'end'}`,
266
284
  ...(tier.kind === 'mega' ? ['sitenav--mega'] : []),
267
285
  ];
268
286
  }
269
287
 
288
+ /**
289
+ * The EmDash widget area that fills a mega-panel item's extra column. `mega-area:<name>` in the
290
+ * item's `cssClasses` wins (labels are translated, so they are unstable); otherwise the last
291
+ * path segment of its URL (`/services/` → `mega-services`). `undefined` when neither exists.
292
+ */
293
+ export function megaAreaName(item: NavItem): string | undefined {
294
+ const explicit = /(?:^|\s)mega-area:([a-z0-9][a-z0-9-]*)(?:\s|$)/.exec(item.cssClasses ?? '');
295
+ if (explicit) return explicit[1];
296
+ const path = (item.url ?? '').split(/[?#]/)[0] ?? '';
297
+ if (!path.startsWith('/') || path.startsWith('//')) return undefined;
298
+ const slug = path.split('/').filter(Boolean).pop();
299
+ const clean = slug
300
+ ?.toLowerCase()
301
+ .replace(/[^a-z0-9-]+/g, '-')
302
+ .replace(/^-+|-+$/g, '');
303
+ return clean ? `mega-${clean}` : undefined;
304
+ }
305
+
270
306
  /** Description text for an item, or undefined. See `NavItem.titleAttr`. */
271
307
  export function itemDescription(item: NavItem): string | undefined {
272
308
  const d = item.titleAttr?.trim();
@@ -61,6 +61,43 @@ const CLICK_ID_PARAMS = [
61
61
  'epik', // Pinterest
62
62
  ] as const;
63
63
 
64
+ // Mirrors PROVIDER_BY_PARAM, UTM_SOURCE_ALIASES, PAID_MEDIUM and DEFAULT_PAID in
65
+ // @getvitops/utils/tracking/phone — copied for the same reason; `tracking.test.ts`
66
+ // pins them. They decide which call-tracking number a visitor is shown (section 4).
67
+ const PROVIDER_BY_PARAM: Record<string, string> = {
68
+ gclid: 'google',
69
+ gbraid: 'google',
70
+ wbraid: 'google',
71
+ fbclid: 'meta',
72
+ li_fat_id: 'linkedin',
73
+ rdt_cid: 'reddit',
74
+ ttclid: 'tiktok',
75
+ msclkid: 'microsoft',
76
+ epik: 'pinterest',
77
+ ScCid: 'snapchat',
78
+ };
79
+ const UTM_SOURCE_ALIASES: Record<string, string> = {
80
+ google: 'google',
81
+ facebook: 'meta',
82
+ instagram: 'meta',
83
+ meta: 'meta',
84
+ fb: 'meta',
85
+ ig: 'meta',
86
+ bing: 'microsoft',
87
+ microsoft: 'microsoft',
88
+ linkedin: 'linkedin',
89
+ reddit: 'reddit',
90
+ tiktok: 'tiktok',
91
+ pinterest: 'pinterest',
92
+ snapchat: 'snapchat',
93
+ snap: 'snapchat',
94
+ };
95
+ const PAID_MEDIUM = /^(cpc|ppc|paid.*|cpm|display|paidsocial|paid_social)$/i;
96
+ const DEFAULT_PAID = 'default-paid';
97
+ // Set on a `tel:` link whose number was swapped, so the click beacon can say which
98
+ // platform's number the visitor called.
99
+ const PHONE_SOURCE_ATTR = 'data-vitops-phone-source';
100
+
64
101
  const UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const;
65
102
 
66
103
  // ---------------------------------------------------------------------------
@@ -201,9 +238,10 @@ document.addEventListener('click', (e) => {
201
238
 
202
239
  // Fire-and-forget beacon; don't block the tel:/mailto: navigation.
203
240
  if (navigator.sendBeacon) {
241
+ const phoneSource = link.getAttribute(PHONE_SOURCE_ATTR);
204
242
  const body = isEmail
205
243
  ? { event: 'email', email: link.href }
206
- : { event: 'call', phone: link.href };
244
+ : { event: 'call', phone: link.href, ...(phoneSource ? { phoneSource } : {}) };
207
245
  navigator.sendBeacon(TRACK_ENDPOINT, JSON.stringify(body));
208
246
  }
209
247
 
@@ -234,6 +272,11 @@ const CONVERTED_ATTR = 'data-vitops-converted';
234
272
  * more than once for the same submission (a retried network call, a
235
273
  * double-invoked effect) reports the conversion once, not twice.
236
274
  *
275
+ * Call it from the fetch handler after a successful POST — not from a
276
+ * success-page query parameter (`?sent=1`). That replays on refresh/back, and
277
+ * the honeypot deliberately redirects to the same success URL, so bots would
278
+ * count as leads. For no-JS forms use `reporting: 'server'` instead.
279
+ *
237
280
  * There is deliberately no server-side equivalent for a no-JS
238
281
  * `<form method="post">` submission — that path is a full-page redirect with no
239
282
  * script execution to fire from, and reporting a Google-Ads-labelled
@@ -252,3 +295,133 @@ function vitopsFormConversion(formEl: HTMLFormElement): void {
252
295
 
253
296
  (window as unknown as { vitopsFormConversion?: typeof vitopsFormConversion }).vitopsFormConversion =
254
297
  vitopsFormConversion;
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // 4. Call-tracking numbers: show a visitor from an ad the number provisioned for it
301
+ // ---------------------------------------------------------------------------
302
+ //
303
+ // The server-rendered HTML always carries the real number (crawlers, JSON-LD and
304
+ // visitors without JavaScript see it); this only ever *swaps*. Two callers:
305
+ //
306
+ // - **Google**, when `site.ads.google.callTracking` is set: Google's own tag fetches
307
+ // a forwarding number and calls `vitopsSwapPhone` (via a queue, because the inline
308
+ // tag and this module load in no fixed order). Google replaces text only — this
309
+ // rewrites the `tel:` href too.
310
+ // - **Everyone else**, from `site.tracking.phoneNumbers`: the number for the
311
+ // platform that sent the visitor. The landing page decides from the URL, which is
312
+ // not storage and needs no permission; later pages decide from `_ac`, which exists
313
+ // only once `marketing` was granted. A visitor who declined therefore sees a
314
+ // tracking number on the landing page only.
315
+
316
+ interface PhoneConfig {
317
+ number: string;
318
+ sources: Record<string, string | undefined>;
319
+ }
320
+
321
+ const PHONE_TEXT = /\+?\d[\d\s().-]{5,18}\d/g;
322
+
323
+ /** Mirrors `phoneDigits`: digits only, a leading North American `1` dropped. */
324
+ function phoneDigits(value: string): string {
325
+ const d = value.replace(/\D/g, '');
326
+ return d.length === 11 && d.startsWith('1') ? d.slice(1) : d;
327
+ }
328
+
329
+ /** Mirrors `telUri`. */
330
+ function telUri(display: string): string {
331
+ const d = display.replace(/\D/g, '');
332
+ return d.length === 10 ? `tel:+1${d}` : `tel:+${d}`;
333
+ }
334
+
335
+ /** Replace every phone-looking run in `node`'s text that names `target` with `display`. */
336
+ function swapText(node: Node, target: string, display: string): void {
337
+ if (node.nodeType === 3) {
338
+ const text = node as Text;
339
+ const next = text.data.replace(PHONE_TEXT, (m) => (phoneDigits(m) === target ? display : m));
340
+ if (next !== text.data) text.data = next;
341
+ return;
342
+ }
343
+ for (const child of Array.from(node.childNodes)) swapText(child, target, display);
344
+ }
345
+
346
+ /**
347
+ * Swap `original` for `display` in every matching `tel:` link (href and text) and in
348
+ * every `[data-vitops-phone]` element's text. Called by Google's tag, so the
349
+ * signature is `(formatted, tel, original)` with the second argument unused: Google's
350
+ * own docs disagree about whether it carries a `+`, so the href is built locally.
351
+ */
352
+ function swapPhone(display: string, _tel: unknown, original: string, source = 'google'): void {
353
+ const target = phoneDigits(original);
354
+ if (!target || phoneDigits(display) === target) return;
355
+ const uri = telUri(display);
356
+ for (const a of Array.from(document.querySelectorAll('a[href^="tel:"]'))) {
357
+ if (phoneDigits((a.getAttribute('href') ?? '').slice(4)) !== target) continue;
358
+ a.setAttribute('href', uri);
359
+ a.setAttribute(PHONE_SOURCE_ATTR, source);
360
+ swapText(a, target, display);
361
+ }
362
+ for (const el of Array.from(document.querySelectorAll('[data-vitops-phone]')))
363
+ swapText(el, target, display);
364
+ }
365
+
366
+ /** Mirrors `selectPhoneNumber`. */
367
+ function selectPhone(
368
+ current: Record<string, unknown> | null,
369
+ stored: Record<string, unknown> | null,
370
+ config: PhoneConfig,
371
+ googleNative: boolean,
372
+ ): { number: string; source: string } | null {
373
+ const providerOf = (data: Record<string, unknown>): string | null => {
374
+ for (const param of Object.keys(PROVIDER_BY_PARAM))
375
+ if (data[param]) return PROVIDER_BY_PARAM[param]!;
376
+ const source = data['utm_source'];
377
+ return typeof source === 'string'
378
+ ? (UTM_SOURCE_ALIASES[source.trim().toLowerCase()] ?? null)
379
+ : null;
380
+ };
381
+ const isPaid = (data: Record<string, unknown>): boolean => {
382
+ if (Object.keys(PROVIDER_BY_PARAM).some((p) => data[p])) return true;
383
+ const medium = data['utm_medium'];
384
+ return typeof medium === 'string' && PAID_MEDIUM.test(medium.trim());
385
+ };
386
+ for (const data of [current, stored]) {
387
+ if (!data) continue;
388
+ const provider = providerOf(data);
389
+ if (provider === 'google' && googleNative) return null;
390
+ const specific = provider ? config.sources[provider] : undefined;
391
+ if (specific && phoneDigits(specific) !== phoneDigits(config.number))
392
+ return { number: specific, source: provider! };
393
+ if (isPaid(data) || provider) {
394
+ const fallback = config.sources[DEFAULT_PAID];
395
+ return fallback && phoneDigits(fallback) !== phoneDigits(config.number)
396
+ ? { number: fallback, source: DEFAULT_PAID }
397
+ : null;
398
+ }
399
+ }
400
+ return null;
401
+ }
402
+
403
+ type SwapFn = (display: string, tel: unknown, original: string) => void;
404
+ const phoneGlobal = window as unknown as {
405
+ vitopsSwapPhone?: SwapFn;
406
+ vitopsPhoneSwaps?: [string, unknown, string][];
407
+ };
408
+ phoneGlobal.vitopsSwapPhone = swapPhone;
409
+ // Google's tag may have called before this module ran; replay what it queued.
410
+ for (const args of phoneGlobal.vitopsPhoneSwaps ?? []) swapPhone(...args);
411
+ phoneGlobal.vitopsPhoneSwaps = [];
412
+
413
+ const phoneAttr = trackingMarker?.getAttribute('data-phone-numbers');
414
+ if (phoneAttr) {
415
+ try {
416
+ const config = JSON.parse(phoneAttr) as PhoneConfig;
417
+ const picked = selectPhone(
418
+ Object.keys(trackingData).length ? trackingData : null,
419
+ readCookie(),
420
+ config,
421
+ trackingMarker?.hasAttribute('data-google-call-tracking') ?? false,
422
+ );
423
+ if (picked) swapPhone(picked.number, null, config.number, picked.source);
424
+ } catch {
425
+ // A malformed attribute leaves the real number in place, which is the safe state.
426
+ }
427
+ }