@getvitops/astro 8.3.0-alpha.3 → 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.
- package/CHANGELOG.md +63 -0
- package/dist/index.d.mts +72 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +166 -2
- package/dist/index.mjs.map +1 -1
- package/dist/routes/conversion.d.mts.map +1 -1
- package/dist/routes/conversion.mjs +7 -1
- package/dist/routes/conversion.mjs.map +1 -1
- package/package.json +10 -6
- package/src/ads.ts +22 -0
- package/src/components/Masthead.astro +49 -0
- package/src/components/SiteFooter.astro +9 -1
- package/src/components/SiteNav.astro +21 -2
- package/src/components/SiteNavList.astro +19 -2
- package/src/components/Tracking.astro +7 -1
- package/src/components/masthead.ts +252 -0
- package/src/components/sitenav.ts +39 -3
- package/src/scripts/tracking.ts +174 -1
|
@@ -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
|
-
|
|
261
|
-
|
|
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
|
-
'
|
|
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();
|
package/src/scripts/tracking.ts
CHANGED
|
@@ -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
|
+
}
|