@getvitops/utils 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.
@@ -0,0 +1,351 @@
1
+ // src/ads/types.ts
2
+ /**
3
+ * A bare string is back-compat shorthand for `{ call: label }` — the only
4
+ * action that existed before this was a map. Absent input normalises to `{}`.
5
+ */
6
+ function normalizeConversionLabels(label) {
7
+ if (label == null) return {};
8
+ return typeof label === "string" ? { call: label } : label;
9
+ }
10
+ /** Same back-compat shorthand as `normalizeConversionLabels`, for ids. */
11
+ function normalizeConversionActionIds(id) {
12
+ if (id == null) return {};
13
+ return typeof id === "string" ? { call: id } : id;
14
+ }
15
+
16
+ // src/ads/providers.ts
17
+ /**
18
+ * What each ad platform actually lets a build tool do — the table every `vitops ads`
19
+ * subcommand reads.
20
+ *
21
+ * Written the way the indexing module was written: start from what the platforms
22
+ * accept, because it is much narrower than it looks, and record the *absence* of a
23
+ * path as a fact rather than leaving it as a gap.
24
+ *
25
+ * - **Four platforms verify a domain by DNS TXT** (Meta, TikTok, Pinterest,
26
+ * Snapchat). That record is the one thing here that can be created for you.
27
+ * - **Four have no domain verification at all** (Google Ads, LinkedIn, Reddit,
28
+ * Microsoft Ads). Linking is the tag and the account id, nothing else. Those
29
+ * resolve to a skip *plus* a named reason — a silent omission would read as
30
+ * "already done".
31
+ * - **No platform Marketing API is called.** Meta's Business Management
32
+ * `verify_domain` needs a system-user token; the Google Ads API needs an
33
+ * approved developer token and puts the consumer's own account on the line.
34
+ * That is the same reasoning that keeps the Google Indexing API unwired: a
35
+ * toolchain other people install must not ship a documented path to a terms
36
+ * violation. The final "Verify" click stays a reminder.
37
+ *
38
+ * The verification token is **not a secret** — it is published in DNS, and the
39
+ * platform fetching it back is the ownership proof, exactly like the IndexNow key.
40
+ * So it lives in the config while credentials stay in the environment.
41
+ *
42
+ * Each tag is the platform's documented bootstrap reduced to its queue stub, with
43
+ * the remote library on `data-src` for the consent gate to attach after the
44
+ * bootstrap has run (`@getvitops/core`'s `activateScript` emits the inline body
45
+ * first for exactly this reason). Two platforms — Microsoft and Pinterest — ship a
46
+ * snippet that creates its own `<script>`; those declare no `src`, and their body
47
+ * stays inert until activation, so an undecided visitor still issues no request.
48
+ */
49
+ /**
50
+ * `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs.
51
+ *
52
+ * `gtag('config', id)` only loads the tag — it never reports a conversion. When
53
+ * `entry.conversionLabel` names one or more actions, this also exposes a
54
+ * `{action: 'id/label'}` map on `window.vitopsAdsConversions.google` — one entry
55
+ * per configured action, so a call, an email click and a form submission each
56
+ * carry their own `send_to` target instead of all three sharing one (that
57
+ * sharing was a real bug: a call and an email click used to report as the same
58
+ * Google Ads conversion action, indistinguishable in the account). Bootstrap
59
+ * time only loads the tag; `scripts/tracking.ts`'s click handler and
60
+ * `vitopsFormConversion()` each read their own action's key and fire
61
+ * `gtag('event', 'conversion', …)` at the moment of the actual interaction —
62
+ * bootstrap-time firing would count every pageview as a conversion.
63
+ *
64
+ * `entry` is trusted here — `resolveAds()` validates every configured label
65
+ * against `SAFE_ID` before it reaches this function, the same guard `id`
66
+ * already gets.
67
+ *
68
+ * **`entry.reporting === 'server'` suppresses an action here, never
69
+ * silently.** An action fires client-side unless reporting is `'server'`
70
+ * *and* that same action has a `conversionActionId` configured — so turning
71
+ * reporting to `'server'` before the id is filled in leaves the action firing
72
+ * client-side rather than dropping the conversion. Once both are set, the
73
+ * server-side upload (`@getvitops/utils/notify`'s `uploadClickConversion`,
74
+ * wired in by `createConversionRoute`'s `googleAdsOffline` option) is the one
75
+ * place that action reports from — this is what avoids double-counting.
76
+ */
77
+ const gtagBootstrap = (id, entry) => {
78
+ const labels = normalizeConversionLabels(entry.conversionLabel);
79
+ const actionIds = normalizeConversionActionIds(entry.conversionActionId);
80
+ const actions = Object.entries(labels).filter((pair) => !!pair[1] && !(entry.reporting === "server" && !!actionIds[pair[0]]));
81
+ return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${id}');` + callTrackingConfig(id, entry) + (actions.length ? `window.vitopsAdsConversions=window.vitopsAdsConversions||{};window.vitopsAdsConversions.google=${JSON.stringify(Object.fromEntries(actions.map(([action, label]) => [action, `${id}/${label}`])))};` : "");
82
+ };
83
+ /**
84
+ * Google's website call conversions: `gtag('config', 'AW-…/<label>', { phone_conversion_number })`.
85
+ *
86
+ * Google fetches a forwarding number for a visitor who arrived from a Google ad and
87
+ * replaces the displayed number's **text** — never the `tel:` href, which Google says
88
+ * needs the callback. The callback hands the result to `vitopsSwapPhone`
89
+ * (`@getvitops/astro`'s tracking script) through a queue, because this inline body and
90
+ * that module load in no guaranteed order: a call that lands first is replayed when the
91
+ * script defines the function, rather than lost.
92
+ *
93
+ * `entry.callTracking` is trusted — `resolveAds()` checks the label against `SAFE_ID`
94
+ * and the number against a phone pattern before it reaches here.
95
+ */
96
+ const callTrackingConfig = (id, entry) => {
97
+ const call = entry.callTracking;
98
+ if (!call) return "";
99
+ const options = call.cache === false ? ",phone_conversion_options:{cache:false}" : "";
100
+ return `window.vitopsPhoneSwaps=window.vitopsPhoneSwaps||[];gtag('config','${id}/${call.label}',{phone_conversion_number:${JSON.stringify(call.number)},phone_conversion_callback:function(f,m){var a=[f,m,${JSON.stringify(call.number)}];window.vitopsSwapPhone?window.vitopsSwapPhone.apply(null,a):window.vitopsPhoneSwaps.push(a)}${options}});`;
101
+ };
102
+ /** The queue-stub shape shared by fbq/ttq/rdt/snaptr/pintrk. */
103
+ const queueStub = (global, methods) => `window.${global}=window.${global}||function(){(window.${global}.q=window.${global}.q||[]).push(arguments)};window.${global}.methods=${methods};`;
104
+ const AD_PLATFORMS = {
105
+ google: {
106
+ name: "Google Ads",
107
+ operatorCountry: "the United States",
108
+ clickIdParams: [
109
+ "gclid",
110
+ "gbraid",
111
+ "wbraid"
112
+ ],
113
+ websiteCallTracking: true,
114
+ consoleUrl: "https://ads.google.com/",
115
+ verification: {
116
+ method: "none",
117
+ reason: "Google Ads has no domain verification — the conversion tag is the link. (Merchant Center claims a website; that is a different product.)"
118
+ },
119
+ tag: {
120
+ needs: "pixelId",
121
+ src: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
122
+ bootstrap: gtagBootstrap,
123
+ cookies: [
124
+ "_gcl_au",
125
+ "_gcl_aw",
126
+ "_gcl_dc",
127
+ "_gac_*"
128
+ ],
129
+ privacyUrl: "https://policies.google.com/privacy",
130
+ optOut: "https://adssettings.google.com/"
131
+ },
132
+ manualSteps: (e) => [
133
+ ...Object.keys(normalizeConversionLabels(e.conversionLabel)).length ? ["confirm each conversion action's category in Google Ads (call → \"Phone call lead\", email → \"Contact\", form → \"Submit lead form\"); the tag cannot set it, and a wrong one reports under the wrong goal"] : ["create a conversion action in Google Ads and record its label as `conversionLabel`"],
134
+ "link the Google Ads account to Search Console / GA4 in the UI if you want imported conversions",
135
+ ...e.callTracking ? [
136
+ "in Google Ads, create a conversion action of type \"Calls from a phone number on your website\", set its minimum call length, and make either it or the `conversionLabel.call` (tap) action secondary so one call is not counted twice",
137
+ "verify the domain through Search Console, not the tag — Google cannot scan a page whose number is swapped (a tag installed through Google Tag Manager can't verify it either)",
138
+ "test with `#google-wcc-debug` appended to a landing URL after granting marketing consent"
139
+ ] : []
140
+ ]
141
+ },
142
+ meta: {
143
+ name: "Meta",
144
+ operatorCountry: "the United States",
145
+ clickIdParams: ["fbclid"],
146
+ websiteCallTracking: false,
147
+ consoleUrl: "https://business.facebook.com/settings/owned-domains",
148
+ verification: {
149
+ method: "dns-txt",
150
+ txtPrefix: "facebook-domain-verification=",
151
+ where: "Business settings → Brand safety → Domains → Add → DNS verification"
152
+ },
153
+ tag: {
154
+ needs: "pixelId",
155
+ src: () => "https://connect.facebook.net/en_US/fbevents.js",
156
+ bootstrap: (id) => `${queueStub("fbq", `['init','track','trackCustom','consent']`)}fbq('init','${id}');fbq('track','PageView');`,
157
+ cookies: ["_fbp", "_fbc"],
158
+ privacyUrl: "https://www.facebook.com/privacy/policy/",
159
+ optOut: "https://www.facebook.com/adpreferences/ad_settings"
160
+ },
161
+ manualSteps: () => ["click Verify on the domain in Business settings once the TXT record has propagated — the Business Management API needs a system-user token and is deliberately not called"]
162
+ },
163
+ linkedin: {
164
+ name: "LinkedIn",
165
+ operatorCountry: "the United States",
166
+ clickIdParams: ["li_fat_id"],
167
+ websiteCallTracking: false,
168
+ consoleUrl: "https://www.linkedin.com/campaignmanager/",
169
+ verification: {
170
+ method: "none",
171
+ reason: "LinkedIn Ads has no domain verification — the Insight Tag is the link. (Verifying a LinkedIn *Page* is a separate, unrelated flow.)"
172
+ },
173
+ tag: {
174
+ needs: "accountId",
175
+ src: () => "https://snap.licdn.com/li.lms-analytics/insight.min.js",
176
+ bootstrap: (id) => `window._linkedin_partner_id='${id}';window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];window._linkedin_data_partner_ids.push('${id}');`,
177
+ cookies: [
178
+ "li_sugr",
179
+ "li_gc",
180
+ "bcookie",
181
+ "lidc",
182
+ "UserMatchHistory"
183
+ ],
184
+ privacyUrl: "https://www.linkedin.com/legal/privacy-policy",
185
+ optOut: "https://www.linkedin.com/psettings/guest-controls/retargeting-opt-out"
186
+ },
187
+ manualSteps: () => ["add conversions in Campaign Manager — the Insight Tag alone records page visits, not conversions"]
188
+ },
189
+ reddit: {
190
+ name: "Reddit",
191
+ operatorCountry: "the United States",
192
+ clickIdParams: ["rdt_cid"],
193
+ websiteCallTracking: false,
194
+ consoleUrl: "https://ads.reddit.com/",
195
+ verification: {
196
+ method: "none",
197
+ reason: "Reddit Ads has no domain verification — the Reddit Pixel is the link."
198
+ },
199
+ tag: {
200
+ needs: "pixelId",
201
+ src: () => "https://www.redditstatic.com/ads/pixel.js",
202
+ bootstrap: (id) => `${queueStub("rdt", `['init','track']`)}rdt('init','${id}');rdt('track','PageVisit');`,
203
+ cookies: ["_rdt_uuid"],
204
+ privacyUrl: "https://www.reddit.com/policies/privacy-policy"
205
+ },
206
+ manualSteps: () => []
207
+ },
208
+ tiktok: {
209
+ name: "TikTok",
210
+ operatorCountry: "Singapore",
211
+ clickIdParams: ["ttclid"],
212
+ websiteCallTracking: false,
213
+ consoleUrl: "https://ads.tiktok.com/",
214
+ verification: {
215
+ method: "dns-txt",
216
+ txtPrefix: "tiktok-developers-site-verification=",
217
+ where: "Ads Manager → Assets → Events → Web events → Verify domain → DNS"
218
+ },
219
+ tag: {
220
+ needs: "pixelId",
221
+ src: (id) => `https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(id)}&lib=ttq`,
222
+ bootstrap: (id) => `window.TiktokAnalyticsObject='ttq';${queueStub("ttq", `['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie']`)}ttq('init','${id}');ttq('page');`,
223
+ cookies: ["_ttp"],
224
+ privacyUrl: "https://www.tiktok.com/legal/privacy-policy"
225
+ },
226
+ manualSteps: () => []
227
+ },
228
+ microsoft: {
229
+ name: "Microsoft Ads",
230
+ operatorCountry: "the United States",
231
+ clickIdParams: ["msclkid"],
232
+ websiteCallTracking: false,
233
+ consoleUrl: "https://ads.microsoft.com/",
234
+ verification: {
235
+ method: "none",
236
+ reason: "Microsoft Ads has no domain verification — the UET tag is the link."
237
+ },
238
+ tag: {
239
+ needs: "pixelId",
240
+ bootstrap: (id) => `(function(w,d,t,r,u){var f,n,i;w[u]=w[u]||[],f=function(){var o={ti:'${id}',enableAutoSpaTracking:true};o.q=w[u],w[u]=new UET(o),w[u].push('pageLoad')},n=d.createElement(t),n.src=r,n.async=1,n.onload=n.onreadystatechange=function(){var s=this.readyState;s&&s!=='loaded'&&s!=='complete'||(f(),n.onload=n.onreadystatechange=null)},i=d.getElementsByTagName(t)[0],i.parentNode.insertBefore(n,i)})(window,document,'script','https://bat.bing.com/bat.js','uetq');`,
241
+ cookies: [
242
+ "_uetsid",
243
+ "_uetvid",
244
+ "MUID"
245
+ ],
246
+ privacyUrl: "https://privacy.microsoft.com/privacystatement",
247
+ optOut: "https://account.microsoft.com/privacy/ad-settings"
248
+ },
249
+ manualSteps: () => []
250
+ },
251
+ pinterest: {
252
+ name: "Pinterest",
253
+ operatorCountry: "the United States",
254
+ clickIdParams: ["epik"],
255
+ websiteCallTracking: false,
256
+ consoleUrl: "https://ads.pinterest.com/",
257
+ verification: {
258
+ method: "dns-txt",
259
+ txtPrefix: "pinterest-site-verification=",
260
+ where: "Business hub → Claim → Websites → Claim → Add TXT record"
261
+ },
262
+ tag: {
263
+ needs: "pixelId",
264
+ bootstrap: (id) => `${queueStub("pintrk", `['load','page','track']`)}!function(){var t=document.createElement('script');t.async=!0,t.src='https://s.pinimg.com/ct/core.js';var e=document.getElementsByTagName('script')[0];e.parentNode.insertBefore(t,e)}();pintrk('load','${id}');pintrk('page');`,
265
+ cookies: [
266
+ "_pinterest_ct_ua",
267
+ "_pin_unauth",
268
+ "_epik"
269
+ ],
270
+ privacyUrl: "https://policy.pinterest.com/privacy-policy"
271
+ },
272
+ manualSteps: () => ["confirm the claim in Business hub → Claim once the TXT record has propagated"]
273
+ },
274
+ snapchat: {
275
+ name: "Snapchat",
276
+ operatorCountry: "the United States",
277
+ clickIdParams: ["ScCid"],
278
+ websiteCallTracking: false,
279
+ consoleUrl: "https://ads.snapchat.com/",
280
+ verification: {
281
+ method: "dns-txt",
282
+ txtPrefix: "snap-domain-verification=",
283
+ where: "Business Manager → Public Profiles / Domains → Verify domain → DNS"
284
+ },
285
+ tag: {
286
+ needs: "pixelId",
287
+ src: () => "https://sc-static.net/scevent.min.js",
288
+ bootstrap: (id) => `${queueStub("snaptr", `['init','track']`)}snaptr('init','${id}');snaptr('track','PAGE_VIEW');`,
289
+ cookies: [
290
+ "_scid",
291
+ "_scid_r",
292
+ "sc_at"
293
+ ],
294
+ privacyUrl: "https://snap.com/privacy/privacy-policy"
295
+ },
296
+ manualSteps: () => []
297
+ }
298
+ };
299
+ /** Every provider, in table order — the one place iteration order is decided. */
300
+ const AD_PROVIDER_KEYS = Object.keys(AD_PLATFORMS);
301
+ /** Whether a string names a known provider (for CLI arg validation). */
302
+ const isAdProvider = (value) => value in AD_PLATFORMS;
303
+ /**
304
+ * The DNS TXT record content for a token.
305
+ *
306
+ * A value already containing `=` is taken as the whole record and left alone. That
307
+ * is the same escape hatch an icon name containing `:` gets: a platform can change
308
+ * its prefix spelling without waiting on a toolchain release, and an operator who
309
+ * pasted the full record from the UI gets what they meant rather than a
310
+ * double-prefixed record that verifies against nothing.
311
+ */
312
+ function txtRecord(provider, token) {
313
+ const trimmed = token.trim();
314
+ if (trimmed.includes("=")) return trimmed;
315
+ return `${AD_PLATFORMS[provider].verification.txtPrefix ?? ""}${trimmed}`;
316
+ }
317
+ /** The tag id for an entry, or undefined when the field it needs is unset. */
318
+ function tagId(entry) {
319
+ return entry[AD_PLATFORMS[entry.provider].tag.needs];
320
+ }
321
+ /** The consent category gating an entry's tag. `marketing` unless stated otherwise. */
322
+ const categoryOf = (entry) => entry.category ?? "marketing";
323
+ const escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
324
+ /**
325
+ * One provider's tag, rendered as a **consent-gated, inert** `<script>`.
326
+ *
327
+ * `type="text/plain"` with the URL on `data-src` is what makes the gate real: the
328
+ * browser neither parses the body nor fetches the library, so an undecided
329
+ * visitor's page issues no third-party request. Never give one of these a live
330
+ * `src` — a gate that instead asks a loaded tracker not to track is a promise.
331
+ *
332
+ * Returns null when the entry has no tag id, so a caller can report the gap rather
333
+ * than emit a snippet that initialises `undefined`.
334
+ */
335
+ function renderTag(entry, strategy = "idle") {
336
+ const platform = AD_PLATFORMS[entry.provider];
337
+ const id = tagId(entry);
338
+ if (!id) return null;
339
+ const attrs = [
340
+ "type=\"text/plain\"",
341
+ "data-vitops-tag",
342
+ `data-consent="${categoryOf(entry)}"`,
343
+ `data-strategy="${strategy}"`,
344
+ ...platform.tag.src ? [`data-src="${escapeAttr(platform.tag.src(id))}"`] : [],
345
+ `data-consent-cookies="${escapeAttr(platform.tag.cookies.join(","))}"`
346
+ ];
347
+ return `<!-- ${platform.name} — gated on ${categoryOf(entry)} consent -->\n<script ${attrs.join(" ")}>${platform.tag.bootstrap(id, entry)}<\/script>`;
348
+ }
349
+
350
+ export { renderTag as a, normalizeConversionActionIds as c, isAdProvider as i, normalizeConversionLabels as l, AD_PROVIDER_KEYS as n, tagId as o, categoryOf as r, txtRecord as s, AD_PLATFORMS as t };
351
+ //# sourceMappingURL=providers-CEHw-j4v.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"providers-CEHw-j4v.mjs","names":[],"sources":["../src/ads/types.ts","../src/ads/providers.ts"],"sourcesContent":["/**\n * The ads module's own option surface.\n *\n * `AdPropertySetup` is the `site.ads.<platform>` block from `@getvitops/config` plus the platform it\n * is keyed by — inferred from the schema `validateConfig` runs, not re-declared here.\n */\n\nimport type { AdPropertyConfig } from '@getvitops/config';\nimport type { AdProvider as AdProviderConfig } from '@getvitops/config/providers';\n\n/**\n * The ad platforms this toolchain knows how to link a site to.\n *\n * A closed set, and `AD_PROVIDERS` in `@getvitops/config` is its single source: the capability\n * table in `providers.ts` is a `Record<AdProvider, …>`, so a provider added there without an entry\n * here is a compile error. (This was a hand-copied union held to the schema's enum by a test in the\n * generator; the schema now lives below this package, so the copy and the test are gone.) An open\n * string would mean a typo'd provider silently getting no verification step and no tag.\n */\nexport type AdProvider = AdProviderConfig;\n\n/** Which consent category gates a provider's tag. */\nexport type AdConsentCategory = 'marketing' | 'analytics';\n\n/**\n * The three interactions Google Ads can report a distinct conversion action\n * for. `form` is fired client-side via `vitopsFormConversion()`\n * (`@getvitops/astro`'s tracking script), the other two from the tel:/mailto:\n * click handler.\n */\nexport type ConversionAction = 'call' | 'email' | 'form';\n\n/**\n * One label per action this site wants Google Ads to tell apart.\n *\n * Written out field by field, `?: string | undefined` — not\n * `Partial<Record<ConversionAction, string>>` — matching this file's own\n * convention above: `exactOptionalPropertyTypes` treats `{ email?: string }`\n * (present-or-absent) and `{ email?: string | undefined }` (present,\n * absent, or explicitly `undefined`) as different types, and a parsed JSON\n * config's optional field is the latter. `Partial<Record<...>>` produces the\n * former, which rejected the zod-inferred shape config.ts actually produces.\n */\nexport interface ConversionLabelMap {\n call?: string | undefined;\n email?: string | undefined;\n form?: string | undefined;\n}\n\n/**\n * A bare string is back-compat shorthand for `{ call: label }` — the only\n * action that existed before this was a map. Absent input normalises to `{}`.\n */\nexport function normalizeConversionLabels(\n label: string | ConversionLabelMap | undefined,\n): ConversionLabelMap {\n if (label == null) return {};\n return typeof label === 'string' ? { call: label } : label;\n}\n\n/**\n * One Google Ads conversion action **resource id** per action this site\n * uploads server-side — a numeric id (`\"123456789\"`) or a full resource name\n * (`\"customers/123/conversionActions/456\"`), never the `conversionLabel`\n * string. Written out field by field for the same `exactOptionalPropertyTypes`\n * reason as `ConversionLabelMap` above.\n *\n * **This is a genuinely different identifier from `conversionLabel`, not an\n * alternate spelling of it.** `conversionLabel` is the opaque label the web\n * tag embeds in `send_to: \"AW-…/label\"` — it identifies the conversion action\n * to `gtag.js`, not to the Ads API. The API's click-conversion upload\n * (`ConversionUploadService.UploadClickConversions`) addresses the action by\n * `conversion_action` **resource name**, a value the label cannot be turned\n * into locally — Google returns both when a conversion action is created, but\n * only the label ships to the browser today. Until something in this\n * toolchain calls `ConversionActionService` to create/list actions and print\n * both back, this field has to be copied in by hand from the Ads UI/API.\n */\nexport interface ConversionActionIdMap {\n call?: string | undefined;\n email?: string | undefined;\n form?: string | undefined;\n}\n\n/** Same back-compat shorthand as `normalizeConversionLabels`, for ids. */\nexport function normalizeConversionActionIds(\n id: string | ConversionActionIdMap | undefined,\n): ConversionActionIdMap {\n if (id == null) return {};\n return typeof id === 'string' ? { call: id } : id;\n}\n\n/** One ad property to link: the config block, plus the platform it is keyed by. */\nexport type AdPropertySetup = AdPropertyConfig & {\n provider: AdProvider;\n};\n\n/** Everything `plan()` needs, once the CLI has read the config. */\nexport interface AdsConfig {\n /** The properties to link. Already filtered to `--provider` if that was passed. */\n properties: AdPropertySetup[];\n}\n\n/**\n * One property's observed live state, gathered by the executor before planning.\n *\n * Same shape of arrangement as `DomainState`: the decision is pure, the\n * observation is not. `txtContents` is every apex TXT record on the domain, so\n * the planner can decide both \"our record is present\" and \"a *different* record\n * from this provider is present\" — the second is a token rotation, which needs\n * saying rather than silently adding a second record.\n */\nexport interface AdDomainState {\n zoneId?: string | undefined;\n /** Every apex TXT record content, unquoted. */\n txtContents: string[];\n}\n","/**\n * What each ad platform actually lets a build tool do — the table every `vitops ads`\n * subcommand reads.\n *\n * Written the way the indexing module was written: start from what the platforms\n * accept, because it is much narrower than it looks, and record the *absence* of a\n * path as a fact rather than leaving it as a gap.\n *\n * - **Four platforms verify a domain by DNS TXT** (Meta, TikTok, Pinterest,\n * Snapchat). That record is the one thing here that can be created for you.\n * - **Four have no domain verification at all** (Google Ads, LinkedIn, Reddit,\n * Microsoft Ads). Linking is the tag and the account id, nothing else. Those\n * resolve to a skip *plus* a named reason — a silent omission would read as\n * \"already done\".\n * - **No platform Marketing API is called.** Meta's Business Management\n * `verify_domain` needs a system-user token; the Google Ads API needs an\n * approved developer token and puts the consumer's own account on the line.\n * That is the same reasoning that keeps the Google Indexing API unwired: a\n * toolchain other people install must not ship a documented path to a terms\n * violation. The final \"Verify\" click stays a reminder.\n *\n * The verification token is **not a secret** — it is published in DNS, and the\n * platform fetching it back is the ownership proof, exactly like the IndexNow key.\n * So it lives in the config while credentials stay in the environment.\n *\n * Each tag is the platform's documented bootstrap reduced to its queue stub, with\n * the remote library on `data-src` for the consent gate to attach after the\n * bootstrap has run (`@getvitops/core`'s `activateScript` emits the inline body\n * first for exactly this reason). Two platforms — Microsoft and Pinterest — ship a\n * snippet that creates its own `<script>`; those declare no `src`, and their body\n * stays inert until activation, so an undecided visitor still issues no request.\n */\nimport { normalizeConversionActionIds, normalizeConversionLabels } from './types.ts';\nimport type { AdConsentCategory, AdProvider, AdPropertySetup } from './types.ts';\n\n/** How a platform proves you own the domain. */\nexport interface AdVerificationSpec {\n method: 'dns-txt' | 'none';\n /**\n * Prefix prepended to a bare token to form the record content. Owned here so a\n * config holds the token the platform shows, not a hand-assembled record — but\n * see `txtRecord`: a value already containing `=` passes through whole, which is\n * the escape hatch when a platform changes its spelling.\n */\n txtPrefix?: string;\n /** Where in the platform UI the token is found. Printed when asking for it. */\n where?: string;\n /** Why there is nothing to automate. Required when `method` is `none`. */\n reason?: string;\n}\n\n/** The tag, and which config field carries its id. */\nexport interface AdTagSpec {\n /** Which field the tag id comes from — and therefore what to ask for when absent. */\n needs: 'pixelId' | 'accountId';\n /** The remote library. Absent when the platform's own bootstrap loads itself. */\n src?: (id: string) => string;\n /** The inline bootstrap, which the gate runs before attaching `src`. */\n bootstrap: (id: string, entry: AdPropertySetup) => string;\n /**\n * Cookies the tag sets, for `data-consent-cookies` (what a revoke clears) and\n * for the generated cookie notice. A `*` suffix is a prefix match.\n */\n cookies: string[];\n /** The provider's own privacy policy, for the cookie notice. */\n privacyUrl: string;\n /** A visitor-facing opt-out, where the platform offers one. */\n optOut?: string;\n}\n\nexport interface AdPlatform {\n /** The platform as a reader would recognise it. */\n name: string;\n /**\n * The jurisdiction that can compel this platform to produce what it receives —\n * where the contracting entity is established. Feeds the generated privacy\n * policy's cross-border transfer disclosure, which is a claim about foreign\n * *access*, not foreign storage (see `Processor.operatorCountry` in the\n * generator's `legal/providers.ts`). A consumer whose contracting entity differs\n * declares their own processor entry instead.\n */\n operatorCountry: string;\n /** Click-ID parameters this platform stamps on an ad landing URL. */\n clickIdParams: string[];\n /**\n * Can the platform itself swap a forwarding number into the website for its own\n * visitors? Only Google can (website call conversions, Canada included). Microsoft's\n * forwarding numbers exist only inside the ad, and only in the US/UK/AU; every other\n * platform offers none. A site wanting a per-platform number provisions its own and\n * states it in `site.tracking.phoneNumbers`.\n */\n websiteCallTracking: boolean;\n /** Where the operator manages the property. */\n consoleUrl: string;\n verification: AdVerificationSpec;\n tag: AdTagSpec;\n /** Follow-ups no API covers, given this entry. */\n manualSteps: (entry: AdPropertySetup) => string[];\n}\n\n/**\n * `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs.\n *\n * `gtag('config', id)` only loads the tag — it never reports a conversion. When\n * `entry.conversionLabel` names one or more actions, this also exposes a\n * `{action: 'id/label'}` map on `window.vitopsAdsConversions.google` — one entry\n * per configured action, so a call, an email click and a form submission each\n * carry their own `send_to` target instead of all three sharing one (that\n * sharing was a real bug: a call and an email click used to report as the same\n * Google Ads conversion action, indistinguishable in the account). Bootstrap\n * time only loads the tag; `scripts/tracking.ts`'s click handler and\n * `vitopsFormConversion()` each read their own action's key and fire\n * `gtag('event', 'conversion', …)` at the moment of the actual interaction —\n * bootstrap-time firing would count every pageview as a conversion.\n *\n * `entry` is trusted here — `resolveAds()` validates every configured label\n * against `SAFE_ID` before it reaches this function, the same guard `id`\n * already gets.\n *\n * **`entry.reporting === 'server'` suppresses an action here, never\n * silently.** An action fires client-side unless reporting is `'server'`\n * *and* that same action has a `conversionActionId` configured — so turning\n * reporting to `'server'` before the id is filled in leaves the action firing\n * client-side rather than dropping the conversion. Once both are set, the\n * server-side upload (`@getvitops/utils/notify`'s `uploadClickConversion`,\n * wired in by `createConversionRoute`'s `googleAdsOffline` option) is the one\n * place that action reports from — this is what avoids double-counting.\n */\nconst gtagBootstrap = (id: string, entry: AdPropertySetup): string => {\n const labels = normalizeConversionLabels(entry.conversionLabel);\n const actionIds = normalizeConversionActionIds(entry.conversionActionId);\n const actions = Object.entries(labels).filter(\n (pair): pair is [string, string] =>\n !!pair[1] &&\n !(entry.reporting === 'server' && !!actionIds[pair[0] as keyof typeof actionIds]),\n );\n return (\n `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}` +\n `gtag('js',new Date());gtag('config','${id}');` +\n callTrackingConfig(id, entry) +\n (actions.length\n ? `window.vitopsAdsConversions=window.vitopsAdsConversions||{};` +\n `window.vitopsAdsConversions.google=${JSON.stringify(\n Object.fromEntries(actions.map(([action, label]) => [action, `${id}/${label}`])),\n )};`\n : '')\n );\n};\n\n/**\n * Google's website call conversions: `gtag('config', 'AW-…/<label>', { phone_conversion_number })`.\n *\n * Google fetches a forwarding number for a visitor who arrived from a Google ad and\n * replaces the displayed number's **text** — never the `tel:` href, which Google says\n * needs the callback. The callback hands the result to `vitopsSwapPhone`\n * (`@getvitops/astro`'s tracking script) through a queue, because this inline body and\n * that module load in no guaranteed order: a call that lands first is replayed when the\n * script defines the function, rather than lost.\n *\n * `entry.callTracking` is trusted — `resolveAds()` checks the label against `SAFE_ID`\n * and the number against a phone pattern before it reaches here.\n */\nconst callTrackingConfig = (id: string, entry: AdPropertySetup): string => {\n const call = entry.callTracking;\n if (!call) return '';\n const options = call.cache === false ? ',phone_conversion_options:{cache:false}' : '';\n return (\n `window.vitopsPhoneSwaps=window.vitopsPhoneSwaps||[];` +\n `gtag('config','${id}/${call.label}',{phone_conversion_number:${JSON.stringify(call.number)},` +\n `phone_conversion_callback:function(f,m){var a=[f,m,${JSON.stringify(call.number)}];` +\n `window.vitopsSwapPhone?window.vitopsSwapPhone.apply(null,a):window.vitopsPhoneSwaps.push(a)}${options}});`\n );\n};\n\n/** The queue-stub shape shared by fbq/ttq/rdt/snaptr/pintrk. */\nconst queueStub = (global: string, methods: string): string =>\n `window.${global}=window.${global}||function(){(window.${global}.q=window.${global}.q||[]).push(arguments)};` +\n `window.${global}.methods=${methods};`;\n\nexport const AD_PLATFORMS: Record<AdProvider, AdPlatform> = {\n google: {\n name: 'Google Ads',\n operatorCountry: 'the United States',\n clickIdParams: ['gclid', 'gbraid', 'wbraid'],\n websiteCallTracking: true,\n consoleUrl: 'https://ads.google.com/',\n verification: {\n method: 'none',\n reason:\n 'Google Ads has no domain verification — the conversion tag is the link. (Merchant Center claims a website; that is a different product.)',\n },\n tag: {\n // The customer id (`123-456-7890`) is not the tag id: the conversion tag is\n // `AW-…`, which is why this asks for `pixelId` rather than reusing accountId.\n needs: 'pixelId',\n src: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,\n bootstrap: gtagBootstrap,\n cookies: ['_gcl_au', '_gcl_aw', '_gcl_dc', '_gac_*'],\n privacyUrl: 'https://policies.google.com/privacy',\n optOut: 'https://adssettings.google.com/',\n },\n manualSteps: (e) => [\n ...(Object.keys(normalizeConversionLabels(e.conversionLabel)).length\n ? [\n 'confirm each conversion action\\'s category in Google Ads (call → \"Phone call lead\", email → \"Contact\", form → \"Submit lead form\"); the tag cannot set it, and a wrong one reports under the wrong goal',\n ]\n : ['create a conversion action in Google Ads and record its label as `conversionLabel`']),\n 'link the Google Ads account to Search Console / GA4 in the UI if you want imported conversions',\n ...(e.callTracking\n ? [\n 'in Google Ads, create a conversion action of type \"Calls from a phone number on your website\", set its minimum call length, and make either it or the `conversionLabel.call` (tap) action secondary so one call is not counted twice',\n \"verify the domain through Search Console, not the tag — Google cannot scan a page whose number is swapped (a tag installed through Google Tag Manager can't verify it either)\",\n 'test with `#google-wcc-debug` appended to a landing URL after granting marketing consent',\n ]\n : []),\n ],\n },\n\n meta: {\n name: 'Meta',\n operatorCountry: 'the United States',\n clickIdParams: ['fbclid'],\n websiteCallTracking: false,\n consoleUrl: 'https://business.facebook.com/settings/owned-domains',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'facebook-domain-verification=',\n where: 'Business settings → Brand safety → Domains → Add → DNS verification',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://connect.facebook.net/en_US/fbevents.js',\n bootstrap: (id) =>\n `${queueStub('fbq', `['init','track','trackCustom','consent']`)}` +\n `fbq('init','${id}');fbq('track','PageView');`,\n cookies: ['_fbp', '_fbc'],\n privacyUrl: 'https://www.facebook.com/privacy/policy/',\n optOut: 'https://www.facebook.com/adpreferences/ad_settings',\n },\n manualSteps: () => [\n 'click Verify on the domain in Business settings once the TXT record has propagated — the Business Management API needs a system-user token and is deliberately not called',\n ],\n },\n\n linkedin: {\n name: 'LinkedIn',\n operatorCountry: 'the United States',\n clickIdParams: ['li_fat_id'],\n websiteCallTracking: false,\n consoleUrl: 'https://www.linkedin.com/campaignmanager/',\n verification: {\n method: 'none',\n reason:\n 'LinkedIn Ads has no domain verification — the Insight Tag is the link. (Verifying a LinkedIn *Page* is a separate, unrelated flow.)',\n },\n tag: {\n needs: 'accountId',\n src: () => 'https://snap.licdn.com/li.lms-analytics/insight.min.js',\n bootstrap: (id) =>\n `window._linkedin_partner_id='${id}';` +\n `window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];` +\n `window._linkedin_data_partner_ids.push('${id}');`,\n cookies: ['li_sugr', 'li_gc', 'bcookie', 'lidc', 'UserMatchHistory'],\n privacyUrl: 'https://www.linkedin.com/legal/privacy-policy',\n optOut: 'https://www.linkedin.com/psettings/guest-controls/retargeting-opt-out',\n },\n manualSteps: () => [\n 'add conversions in Campaign Manager — the Insight Tag alone records page visits, not conversions',\n ],\n },\n\n reddit: {\n name: 'Reddit',\n operatorCountry: 'the United States',\n clickIdParams: ['rdt_cid'],\n websiteCallTracking: false,\n consoleUrl: 'https://ads.reddit.com/',\n verification: {\n method: 'none',\n reason: 'Reddit Ads has no domain verification — the Reddit Pixel is the link.',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://www.redditstatic.com/ads/pixel.js',\n bootstrap: (id) =>\n `${queueStub('rdt', `['init','track']`)}rdt('init','${id}');rdt('track','PageVisit');`,\n cookies: ['_rdt_uuid'],\n privacyUrl: 'https://www.reddit.com/policies/privacy-policy',\n },\n manualSteps: () => [],\n },\n\n tiktok: {\n name: 'TikTok',\n operatorCountry: 'Singapore',\n clickIdParams: ['ttclid'],\n websiteCallTracking: false,\n consoleUrl: 'https://ads.tiktok.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'tiktok-developers-site-verification=',\n where: 'Ads Manager → Assets → Events → Web events → Verify domain → DNS',\n },\n tag: {\n needs: 'pixelId',\n src: (id) =>\n `https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(id)}&lib=ttq`,\n bootstrap: (id) =>\n `window.TiktokAnalyticsObject='ttq';` +\n `${queueStub('ttq', `['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie']`)}` +\n `ttq('init','${id}');ttq('page');`,\n cookies: ['_ttp'],\n privacyUrl: 'https://www.tiktok.com/legal/privacy-policy',\n },\n manualSteps: () => [],\n },\n\n microsoft: {\n name: 'Microsoft Ads',\n operatorCountry: 'the United States',\n clickIdParams: ['msclkid'],\n websiteCallTracking: false,\n consoleUrl: 'https://ads.microsoft.com/',\n verification: {\n method: 'none',\n reason: 'Microsoft Ads has no domain verification — the UET tag is the link.',\n },\n tag: {\n needs: 'pixelId',\n // No `src`: UET must be constructed *after* bat.js evaluates, so the\n // platform's own snippet attaches an onload handler to a script it creates.\n // Inert until activation all the same — the body is never parsed before then.\n bootstrap: (id) =>\n `(function(w,d,t,r,u){var f,n,i;w[u]=w[u]||[],f=function(){var o={ti:'${id}',enableAutoSpaTracking:true};` +\n `o.q=w[u],w[u]=new UET(o),w[u].push('pageLoad')},n=d.createElement(t),n.src=r,n.async=1,` +\n `n.onload=n.onreadystatechange=function(){var s=this.readyState;s&&s!=='loaded'&&s!=='complete'||(f(),n.onload=n.onreadystatechange=null)},` +\n `i=d.getElementsByTagName(t)[0],i.parentNode.insertBefore(n,i)})(window,document,'script','https://bat.bing.com/bat.js','uetq');`,\n cookies: ['_uetsid', '_uetvid', 'MUID'],\n privacyUrl: 'https://privacy.microsoft.com/privacystatement',\n optOut: 'https://account.microsoft.com/privacy/ad-settings',\n },\n manualSteps: () => [],\n },\n\n pinterest: {\n name: 'Pinterest',\n operatorCountry: 'the United States',\n clickIdParams: ['epik'],\n websiteCallTracking: false,\n consoleUrl: 'https://ads.pinterest.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'pinterest-site-verification=',\n where: 'Business hub → Claim → Websites → Claim → Add TXT record',\n },\n tag: {\n needs: 'pixelId',\n // Pinterest's stub creates the script itself once `pintrk('load')` runs.\n bootstrap: (id) =>\n `${queueStub('pintrk', `['load','page','track']`)}` +\n `!function(){var t=document.createElement('script');t.async=!0,t.src='https://s.pinimg.com/ct/core.js';` +\n `var e=document.getElementsByTagName('script')[0];e.parentNode.insertBefore(t,e)}();` +\n `pintrk('load','${id}');pintrk('page');`,\n cookies: ['_pinterest_ct_ua', '_pin_unauth', '_epik'],\n privacyUrl: 'https://policy.pinterest.com/privacy-policy',\n },\n manualSteps: () => [\n 'confirm the claim in Business hub → Claim once the TXT record has propagated',\n ],\n },\n\n snapchat: {\n name: 'Snapchat',\n operatorCountry: 'the United States',\n clickIdParams: ['ScCid'],\n websiteCallTracking: false,\n consoleUrl: 'https://ads.snapchat.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'snap-domain-verification=',\n where: 'Business Manager → Public Profiles / Domains → Verify domain → DNS',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://sc-static.net/scevent.min.js',\n bootstrap: (id) =>\n `${queueStub('snaptr', `['init','track']`)}snaptr('init','${id}');snaptr('track','PAGE_VIEW');`,\n cookies: ['_scid', '_scid_r', 'sc_at'],\n privacyUrl: 'https://snap.com/privacy/privacy-policy',\n },\n manualSteps: () => [],\n },\n};\n\n/** Every provider, in table order — the one place iteration order is decided. */\nexport const AD_PROVIDER_KEYS = Object.keys(AD_PLATFORMS) as AdProvider[];\n\n/** Whether a string names a known provider (for CLI arg validation). */\nexport const isAdProvider = (value: string): value is AdProvider => value in AD_PLATFORMS;\n\n/**\n * The DNS TXT record content for a token.\n *\n * A value already containing `=` is taken as the whole record and left alone. That\n * is the same escape hatch an icon name containing `:` gets: a platform can change\n * its prefix spelling without waiting on a toolchain release, and an operator who\n * pasted the full record from the UI gets what they meant rather than a\n * double-prefixed record that verifies against nothing.\n */\nexport function txtRecord(provider: AdProvider, token: string): string {\n const trimmed = token.trim();\n if (trimmed.includes('=')) return trimmed;\n return `${AD_PLATFORMS[provider].verification.txtPrefix ?? ''}${trimmed}`;\n}\n\n/** The tag id for an entry, or undefined when the field it needs is unset. */\nexport function tagId(entry: AdPropertySetup): string | undefined {\n const field = AD_PLATFORMS[entry.provider].tag.needs;\n return entry[field];\n}\n\n/** The consent category gating an entry's tag. `marketing` unless stated otherwise. */\nexport const categoryOf = (entry: AdPropertySetup): AdConsentCategory =>\n entry.category ?? 'marketing';\n\nconst escapeAttr = (value: string): string =>\n value.replace(/&/g, '&amp;').replace(/\"/g, '&quot;').replace(/</g, '&lt;');\n\n/**\n * One provider's tag, rendered as a **consent-gated, inert** `<script>`.\n *\n * `type=\"text/plain\"` with the URL on `data-src` is what makes the gate real: the\n * browser neither parses the body nor fetches the library, so an undecided\n * visitor's page issues no third-party request. Never give one of these a live\n * `src` — a gate that instead asks a loaded tracker not to track is a promise.\n *\n * Returns null when the entry has no tag id, so a caller can report the gap rather\n * than emit a snippet that initialises `undefined`.\n */\nexport function renderTag(\n entry: AdPropertySetup,\n strategy: 'idle' | 'async' | 'interaction' = 'idle',\n): string | null {\n const platform = AD_PLATFORMS[entry.provider];\n const id = tagId(entry);\n if (!id) return null;\n const attrs = [\n 'type=\"text/plain\"',\n 'data-vitops-tag',\n `data-consent=\"${categoryOf(entry)}\"`,\n `data-strategy=\"${strategy}\"`,\n ...(platform.tag.src ? [`data-src=\"${escapeAttr(platform.tag.src(id))}\"`] : []),\n `data-consent-cookies=\"${escapeAttr(platform.tag.cookies.join(','))}\"`,\n ];\n return `<!-- ${platform.name} — gated on ${categoryOf(entry)} consent -->\\n<script ${attrs.join(' ')}>${platform.tag.bootstrap(id, entry)}</script>`;\n}\n"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
- import { i as TrackingData, n as TrackingCategory, r as TrackingConfig, t as ConversionEvent } from "../types-oac2P5or.mjs";
1
+ import { i as AdProvider } from "../types-C0hQKdsM.mjs";
2
+ import { i as TrackingData, n as TrackingCategory, r as TrackingConfig, t as ConversionEvent } from "../types-ByK6OuXQ.mjs";
2
3
  /**
3
4
  * The ad-platform vocabulary — the one place that says which URL parameters mean
4
5
  * "this visit came from an ad", and which platform each belongs to.
@@ -83,6 +84,56 @@ export declare function getPrimaryClickId(data: TrackingData): {
83
84
  param: string;
84
85
  value: string;
85
86
  } | null;
87
+ /** The fallback key: any paid visit with no platform-specific number. */
88
+ export declare const DEFAULT_PAID = "default-paid";
89
+ interface PhoneNumbersConfig {
90
+ /** The real number, exactly as it is displayed on the page. */
91
+ number: string;
92
+ /** One tracking number per platform, plus an optional `default-paid` fallback. */
93
+ sources: Readonly<Record<string, string | undefined>>;
94
+ }
95
+ interface SelectedNumber {
96
+ /** The number to display, in the format it was configured in. */
97
+ number: string;
98
+ /** The `sources` key that matched, for the conversion notification. */
99
+ source: string;
100
+ }
101
+ /** Click-ID parameter → the `site.ads` provider key it belongs to. */
102
+ export declare const PROVIDER_BY_PARAM: Readonly<Record<string, AdProvider>>;
103
+ /**
104
+ * `utm_source` values people actually write → provider key. A UTM is self-declared, so
105
+ * this is matched loosely; a click ID needs no such help.
106
+ */
107
+ export declare const UTM_SOURCE_ALIASES: Readonly<Record<string, AdProvider>>;
108
+ /** `utm_medium` values that mean "paid" and so qualify for `default-paid`. */
109
+ export declare const PAID_MEDIUM: RegExp;
110
+ /**
111
+ * The digits of a phone number with a leading North American `1` removed, so
112
+ * `+1 (613) 555-0100`, `613-555-0100` and `tel:+16135550100` all compare equal.
113
+ */
114
+ export declare function phoneDigits(value: string): string;
115
+ /** Do two displayed numbers name the same line? Empty never matches. */
116
+ export declare function samePhone(a: string, b: string): boolean;
117
+ /**
118
+ * The `tel:` URI for a displayed number. A bare ten-digit number is North American
119
+ * (this toolchain's market) and gets `+1`; anything already carrying a country code
120
+ * keeps it.
121
+ */
122
+ export declare function telUri(display: string): string;
123
+ /**
124
+ * Pick the number for this visit, or null to leave the real one alone.
125
+ *
126
+ * `current` is what this page's URL carried and `stored` is the `_ac` cookie. The URL
127
+ * wins: a visitor arriving from a Meta ad today with a stale Google click ID stored
128
+ * from last month is a Meta visitor now. An organic visit (neither carries anything)
129
+ * always returns null — nobody is shown a tracking number they were not sent to.
130
+ *
131
+ * `googleNative` is true when `site.ads.google.callTracking` is configured; Google's
132
+ * tag owns that swap, so a Google visit returns null here rather than competing.
133
+ */
134
+ export declare function selectPhoneNumber(current: TrackingData | null, stored: TrackingData | null, config: PhoneNumbersConfig | undefined, opts?: {
135
+ googleNative?: boolean;
136
+ }): SelectedNumber | null;
86
137
  /**
87
138
  * Pulling a submitter's identity out of a form's fields.
88
139
  *
@@ -108,5 +159,5 @@ interface SubmitterIdentity {
108
159
  company?: string | undefined;
109
160
  }
110
161
  export declare function identifySubmitter(formData: Record<string, string>): SubmitterIdentity;
111
- export type { ConversionEvent, SubmitterIdentity, TrackingCategory, TrackingConfig, TrackingData };
162
+ export type { ConversionEvent, PhoneNumbersConfig, SelectedNumber, SubmitterIdentity, TrackingCategory, TrackingConfig, TrackingData };
112
163
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/tracking/params.ts","../../src/tracking/cookie.ts","../../src/tracking/platform.ts","../../src/tracking/identity.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;qBAuBa,iBAAiB,SAAS;;qBAc1B;qBAEA;;qBASA;;;;;;;;qBASA;;qBAGA;;qBAGA;;wBChDG,WAAW,yCAAyC;;;;;;;;;wBAiBpD,oBAAoB,0CAA0C;;wBAa9D,wBAAwB,MAAM;;;;;;;;;wBAe9B,iBAAiB,aAAa;;;;;;;;;wBAa9B,cACd,UAAU,qBACV,UAAU,cACV,cACC;;;;;;;;wBC3Da,iBAAiB,MAAM;;wBASvB,kBAAkB,MAAM;EAAiB;EAAe;;;;;;;;;;;;;;;;UCZvD;EACf;EACA;;;EAGA;EACA;EACA;EACA;;wBAWc,kBAAkB,UAAU,yBAAyB"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/tracking/params.ts","../../src/tracking/cookie.ts","../../src/tracking/platform.ts","../../src/tracking/phone.ts","../../src/tracking/identity.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;qBAuBa,iBAAiB,SAAS;;qBAc1B;qBAEA;;qBASA;;;;;;;;qBASA;;qBAGA;;qBAGA;;wBChDG,WAAW,yCAAyC;;;;;;;;;wBAiBpD,oBAAoB,0CAA0C;;wBAa9D,wBAAwB,MAAM;;;;;;;;;wBAe9B,iBAAiB,aAAa;;;;;;;;;wBAa9B,cACd,UAAU,qBACV,UAAU,cACV,cACC;;;;;;;;wBC3Da,iBAAiB,MAAM;;wBASvB,kBAAkB,MAAM;EAAiB;EAAe;;;qBCR3D;UAEI;;EAEf;;EAEA,SAAS,SAAS;;UAGH;;EAEf;;EAEA;;;qBAIW,mBAAmB,SAAS,eAAe;;;;;qBAU3C,oBAAoB,SAAS,eAAe;;qBAkB5C,aAAW;;;;;wBAMR,YAAY;;wBAMZ,UAAU,WAAW;;;;;;wBAUrB,OAAO;;;;;;;;;;;;wBAkCP,kBACd,SAAS,qBACT,QAAQ,qBACR,QAAQ,gCACR;EAAQ;IACP;;;;;;;;;;;;;;;UC9Gc;EACf;EACA;;;EAGA;EACA;EACA;EACA;;wBAWc,kBAAkB,UAAU,yBAAyB"}
@@ -1,3 +1,4 @@
1
+ import { t as AD_PLATFORMS } from "../providers-CEHw-j4v.mjs";
1
2
  import { t as identifySubmitter } from "../identity-C4yInICW.mjs";
2
3
  import { a as PLATFORM_PARAMS, c as TRACKING_COOKIE_DAYS, i as CLICK_ID_PARAMS, l as UTM_PARAMS, n as identifyPlatform, o as TRACKED_PARAMS, r as AB_COOKIE, s as TRACKING_COOKIE, t as getPrimaryClickId } from "../platform-Cb_RtsF1.mjs";
3
4
 
@@ -73,5 +74,114 @@ function mergeTracking(existing, captured, now) {
73
74
  };
74
75
  }
75
76
 
76
- export { AB_COOKIE, CLICK_ID_PARAMS, PLATFORM_PARAMS, TRACKED_PARAMS, TRACKING_COOKIE, TRACKING_COOKIE_DAYS, UTM_PARAMS, cookieAttributes, getPrimaryClickId, identifyPlatform, identifySubmitter, mergeTracking, parseTrackingCookie, readCookie, serializeTrackingCookie };
77
+ // src/tracking/phone.ts
78
+ /**
79
+ * Which phone number a visitor should be shown — the pure decision behind call tracking.
80
+ *
81
+ * `site.tracking.phoneNumbers` records facts: the real number, and one number per ad
82
+ * platform the client has provisioned with a call-tracking provider (Twilio, CallRail,
83
+ * …), each forwarding to the real line. This module decides which one a given arrival
84
+ * gets. It touches no DOM, cookie or clock, so it runs in a Worker and in a test, and
85
+ * `scripts/tracking.ts` in `@getvitops/astro` mirrors it (that file is inlined into the
86
+ * document and cannot import) — `tracking.test.ts` there pins the two.
87
+ *
88
+ * Google is deliberately absent from the swap when `site.ads.google.callTracking` is
89
+ * set: Google's own tag fetches and inserts its forwarding number, and swapping a second
90
+ * number on top would show a visitor one number and attribute the call to another.
91
+ */
92
+ /** The fallback key: any paid visit with no platform-specific number. */
93
+ const DEFAULT_PAID = "default-paid";
94
+ /** Click-ID parameter → the `site.ads` provider key it belongs to. */
95
+ const PROVIDER_BY_PARAM = Object.fromEntries(Object.keys(AD_PLATFORMS).flatMap((provider) => AD_PLATFORMS[provider].clickIdParams.map((param) => [param, provider])));
96
+ /**
97
+ * `utm_source` values people actually write → provider key. A UTM is self-declared, so
98
+ * this is matched loosely; a click ID needs no such help.
99
+ */
100
+ const UTM_SOURCE_ALIASES = {
101
+ google: "google",
102
+ facebook: "meta",
103
+ instagram: "meta",
104
+ meta: "meta",
105
+ fb: "meta",
106
+ ig: "meta",
107
+ bing: "microsoft",
108
+ microsoft: "microsoft",
109
+ linkedin: "linkedin",
110
+ reddit: "reddit",
111
+ tiktok: "tiktok",
112
+ pinterest: "pinterest",
113
+ snapchat: "snapchat",
114
+ snap: "snapchat"
115
+ };
116
+ /** `utm_medium` values that mean "paid" and so qualify for `default-paid`. */
117
+ const PAID_MEDIUM = /^(cpc|ppc|paid.*|cpm|display|paidsocial|paid_social)$/i;
118
+ /**
119
+ * The digits of a phone number with a leading North American `1` removed, so
120
+ * `+1 (613) 555-0100`, `613-555-0100` and `tel:+16135550100` all compare equal.
121
+ */
122
+ function phoneDigits(value) {
123
+ const digits = value.replace(/\D/g, "");
124
+ return digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits;
125
+ }
126
+ /** Do two displayed numbers name the same line? Empty never matches. */
127
+ function samePhone(a, b) {
128
+ const da = phoneDigits(a);
129
+ return da.length > 0 && da === phoneDigits(b);
130
+ }
131
+ /**
132
+ * The `tel:` URI for a displayed number. A bare ten-digit number is North American
133
+ * (this toolchain's market) and gets `+1`; anything already carrying a country code
134
+ * keeps it.
135
+ */
136
+ function telUri(display) {
137
+ const digits = display.replace(/\D/g, "");
138
+ if (digits.length === 10) return `tel:+1${digits}`;
139
+ return `tel:+${digits}`;
140
+ }
141
+ /** Which provider a set of tracking data points at, by click ID first and `utm_source` second. */
142
+ function providerOf(data) {
143
+ for (const [param, provider] of Object.entries(PROVIDER_BY_PARAM)) if (data[param]) return provider;
144
+ const source = data["utm_source"];
145
+ return typeof source === "string" ? UTM_SOURCE_ALIASES[source.trim().toLowerCase()] ?? null : null;
146
+ }
147
+ function isPaid(data) {
148
+ for (const param of Object.keys(PROVIDER_BY_PARAM)) if (data[param]) return true;
149
+ const medium = data["utm_medium"];
150
+ return typeof medium === "string" && PAID_MEDIUM.test(medium.trim());
151
+ }
152
+ /**
153
+ * Pick the number for this visit, or null to leave the real one alone.
154
+ *
155
+ * `current` is what this page's URL carried and `stored` is the `_ac` cookie. The URL
156
+ * wins: a visitor arriving from a Meta ad today with a stale Google click ID stored
157
+ * from last month is a Meta visitor now. An organic visit (neither carries anything)
158
+ * always returns null — nobody is shown a tracking number they were not sent to.
159
+ *
160
+ * `googleNative` is true when `site.ads.google.callTracking` is configured; Google's
161
+ * tag owns that swap, so a Google visit returns null here rather than competing.
162
+ */
163
+ function selectPhoneNumber(current, stored, config, opts = {}) {
164
+ if (!config) return null;
165
+ for (const data of [current, stored]) {
166
+ if (!data) continue;
167
+ const provider = providerOf(data);
168
+ if (provider === "google" && opts.googleNative) return null;
169
+ const specific = provider ? config.sources[provider] : void 0;
170
+ if (specific && !samePhone(specific, config.number)) return {
171
+ number: specific,
172
+ source: provider
173
+ };
174
+ if (isPaid(data) || provider) {
175
+ const fallback = config.sources[DEFAULT_PAID];
176
+ if (fallback && !samePhone(fallback, config.number) && !(provider === "google" && opts.googleNative)) return {
177
+ number: fallback,
178
+ source: DEFAULT_PAID
179
+ };
180
+ return null;
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ export { AB_COOKIE, CLICK_ID_PARAMS, DEFAULT_PAID, PAID_MEDIUM, PLATFORM_PARAMS, PROVIDER_BY_PARAM, TRACKED_PARAMS, TRACKING_COOKIE, TRACKING_COOKIE_DAYS, UTM_PARAMS, UTM_SOURCE_ALIASES, cookieAttributes, getPrimaryClickId, identifyPlatform, identifySubmitter, mergeTracking, parseTrackingCookie, phoneDigits, readCookie, samePhone, selectPhoneNumber, serializeTrackingCookie, telUri };
77
187
  //# sourceMappingURL=index.mjs.map