@escape-game-over/atlas 0.1.55 → 0.1.57
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/package.json +1 -1
- package/src/analytics/index.ts +14 -0
- package/src/analytics/snap-pixel.ts +25 -0
- package/src/analytics/tiktok-pixel.ts +23 -0
- package/src/astro/ConsentBanner.astro +5 -1
- package/src/astro/SnapPixel.astro +18 -0
- package/src/astro/TikTokPixel.astro +18 -0
- package/src/astro/append-script.ts +8 -0
- package/src/astro/client.ts +2 -0
- package/src/astro/google-map.ts +10 -7
- package/src/astro/meta-pixel.ts +10 -8
- package/src/astro/snap-event.ts +59 -0
- package/src/astro/snap-pixel.ts +69 -0
- package/src/astro/tiktok-event.ts +59 -0
- package/src/astro/tiktok-pixel.ts +112 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
package/src/analytics/index.ts
CHANGED
|
@@ -2,7 +2,9 @@ import type { MetaTag } from "../meta/tag.ts";
|
|
|
2
2
|
import type { HttpsUrl } from "../url.ts";
|
|
3
3
|
import { type GoogleSettings, googleEmits, googleScripts } from "./google.ts";
|
|
4
4
|
import { checkMetaPixel, type MetaPixelSettings } from "./meta-pixel.ts";
|
|
5
|
+
import { checkSnapPixel, type SnapPixelSettings } from "./snap-pixel.ts";
|
|
5
6
|
import type { AnalyticsTags } from "./tags.ts";
|
|
7
|
+
import { checkTikTokPixel, type TikTokPixelSettings } from "./tiktok-pixel.ts";
|
|
6
8
|
import {
|
|
7
9
|
checkUmamiDomains,
|
|
8
10
|
type UmamiSettings,
|
|
@@ -31,7 +33,9 @@ export {
|
|
|
31
33
|
type GoogleSettings,
|
|
32
34
|
} from "./google.ts";
|
|
33
35
|
export type { MetaPixelSettings } from "./meta-pixel.ts";
|
|
36
|
+
export type { SnapPixelSettings } from "./snap-pixel.ts";
|
|
34
37
|
export type { AnalyticsTags } from "./tags.ts";
|
|
38
|
+
export type { TikTokPixelSettings } from "./tiktok-pixel.ts";
|
|
35
39
|
export type {
|
|
36
40
|
UmamiReplay,
|
|
37
41
|
UmamiSettings,
|
|
@@ -44,6 +48,10 @@ export interface AnalyticsSettings {
|
|
|
44
48
|
readonly google?: GoogleSettings;
|
|
45
49
|
/** Loaded by `ConsentBanner`, after marketing is granted. */
|
|
46
50
|
readonly meta?: MetaPixelSettings;
|
|
51
|
+
/** Loaded by `ConsentBanner`, after marketing is granted. */
|
|
52
|
+
readonly tiktok?: TikTokPixelSettings;
|
|
53
|
+
/** Loaded by `ConsentBanner`, after marketing is granted. */
|
|
54
|
+
readonly snapchat?: SnapPixelSettings;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/**
|
|
@@ -65,6 +73,8 @@ export function analyticsScripts(
|
|
|
65
73
|
|
|
66
74
|
checkUmamiDomains(analytics.umami, origin);
|
|
67
75
|
checkMetaPixel(analytics.meta, origin);
|
|
76
|
+
checkTikTokPixel(analytics.tiktok, origin);
|
|
77
|
+
checkSnapPixel(analytics.snapchat, origin);
|
|
68
78
|
const google = googleScripts(analytics.google, origin);
|
|
69
79
|
return {
|
|
70
80
|
head: [...google.head, ...umamiScripts(analytics.umami)],
|
|
@@ -105,6 +115,10 @@ export function consentVendors(
|
|
|
105
115
|
google: googleEmits(analytics.google),
|
|
106
116
|
// No cookieless mode: nothing loads until marketing is granted.
|
|
107
117
|
meta: analytics.meta !== undefined,
|
|
118
|
+
// Its Cookie Consent Mode holds events back; it is not cookieless.
|
|
119
|
+
tiktok: analytics.tiktok !== undefined,
|
|
120
|
+
// No consent mode at all.
|
|
121
|
+
snapchat: analytics.snapchat !== undefined,
|
|
108
122
|
};
|
|
109
123
|
|
|
110
124
|
return (Object.keys(VENDORS) as (keyof AnalyticsSettings)[]).filter(
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NonEmpty } from "../types.ts";
|
|
2
|
+
import { warn } from "../warn.ts";
|
|
3
|
+
|
|
4
|
+
/** Snapchat's pixel. Marketing: it loads only once a visitor grants that. */
|
|
5
|
+
export interface SnapPixelSettings {
|
|
6
|
+
/** From Snap's Events Manager: a UUID. */
|
|
7
|
+
readonly pixelIds: NonEmpty<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
|
|
12
|
+
/** Warns about an id that is not a pixel id: it would report nowhere. */
|
|
13
|
+
export function checkSnapPixel(
|
|
14
|
+
snapchat: SnapPixelSettings | undefined,
|
|
15
|
+
at: string
|
|
16
|
+
): void {
|
|
17
|
+
for (const id of snapchat?.pixelIds ?? []) {
|
|
18
|
+
if (!UUID.test(id)) {
|
|
19
|
+
warn(
|
|
20
|
+
at,
|
|
21
|
+
`Snap pixel id "${id}" is not a UUID, so it reports nowhere. Copy it from Snap's Events Manager.`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NonEmpty } from "../types.ts";
|
|
2
|
+
import { warn } from "../warn.ts";
|
|
3
|
+
|
|
4
|
+
/** TikTok's pixel. Marketing: it loads only once a visitor grants that. */
|
|
5
|
+
export interface TikTokPixelSettings {
|
|
6
|
+
/** From TikTok Events Manager: 20 capital letters and digits. */
|
|
7
|
+
readonly pixelIds: NonEmpty<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Warns about an id that is not a pixel id: it would report nowhere. */
|
|
11
|
+
export function checkTikTokPixel(
|
|
12
|
+
tiktok: TikTokPixelSettings | undefined,
|
|
13
|
+
at: string
|
|
14
|
+
): void {
|
|
15
|
+
for (const id of tiktok?.pixelIds ?? []) {
|
|
16
|
+
if (!/^[A-Z0-9]{20}$/.test(id)) {
|
|
17
|
+
warn(
|
|
18
|
+
at,
|
|
19
|
+
`TikTok pixel id "${id}" is not 20 capital letters and digits, so it reports nowhere. Copy it from TikTok Events Manager.`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
* buttons.
|
|
6
6
|
*
|
|
7
7
|
* Renders nothing — and ships no script — when this site's analytics need no
|
|
8
|
-
* permission. Also loads the vendors that wait for consent, such as Meta
|
|
8
|
+
* permission. Also loads the vendors that wait for consent, such as the Meta, TikTok and Snap pixels. Otherwise the banner starts hidden and shows only to a visitor
|
|
9
9
|
* with no answer on record, or when a `data-consent-reopen` control is pressed.
|
|
10
10
|
*/
|
|
11
11
|
import type { HTMLAttributes } from "astro/types";
|
|
12
12
|
import { type AnalyticsSettings, consentRequired } from "../analytics/index.ts";
|
|
13
13
|
import ConsentElement from "./ConsentElement.astro";
|
|
14
14
|
import MetaPixel from "./MetaPixel.astro";
|
|
15
|
+
import SnapPixel from "./SnapPixel.astro";
|
|
16
|
+
import TikTokPixel from "./TikTokPixel.astro";
|
|
15
17
|
|
|
16
18
|
type Props = HTMLAttributes<"div"> & {
|
|
17
19
|
readonly analytics: AnalyticsSettings | undefined;
|
|
@@ -26,5 +28,7 @@ const { analytics, ...attrs } = Astro.props;
|
|
|
26
28
|
<slot />
|
|
27
29
|
</ConsentElement>
|
|
28
30
|
{analytics?.meta && <MetaPixel settings={analytics.meta} />}
|
|
31
|
+
{analytics?.tiktok && <TikTokPixel settings={analytics.tiktok} />}
|
|
32
|
+
{analytics?.snapchat && <SnapPixel settings={analytics.snapchat} />}
|
|
29
33
|
</>
|
|
30
34
|
)}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Internal to `ConsentBanner.astro`, so the pixel's script ships only where it is configured. */
|
|
3
|
+
import type { SnapPixelSettings } from "../analytics/snap-pixel.ts";
|
|
4
|
+
import AtlasElement from "./AtlasElement.astro";
|
|
5
|
+
import { snapPixelElement } from "./snap-pixel.ts";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
readonly settings: SnapPixelSettings;
|
|
9
|
+
}
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<script src="./snap-pixel.ts" />
|
|
13
|
+
|
|
14
|
+
<AtlasElement
|
|
15
|
+
of={snapPixelElement}
|
|
16
|
+
data-pixel-ids={JSON.stringify(Astro.props.settings.pixelIds)}
|
|
17
|
+
hidden
|
|
18
|
+
/>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Internal to `ConsentBanner.astro`, so the pixel's script ships only where it is configured. */
|
|
3
|
+
import type { TikTokPixelSettings } from "../analytics/tiktok-pixel.ts";
|
|
4
|
+
import AtlasElement from "./AtlasElement.astro";
|
|
5
|
+
import { tiktokPixelElement } from "./tiktok-pixel.ts";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
readonly settings: TikTokPixelSettings;
|
|
9
|
+
}
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<script src="./tiktok-pixel.ts" />
|
|
13
|
+
|
|
14
|
+
<AtlasElement
|
|
15
|
+
of={tiktokPixelElement}
|
|
16
|
+
data-pixel-ids={JSON.stringify(Astro.props.settings.pixelIds)}
|
|
17
|
+
hidden
|
|
18
|
+
/>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Fetches a vendor's script without blocking the page. */
|
|
2
|
+
export function appendScript(src: string): HTMLScriptElement {
|
|
3
|
+
const script = document.createElement("script");
|
|
4
|
+
script.async = true;
|
|
5
|
+
script.src = src;
|
|
6
|
+
document.head.append(script);
|
|
7
|
+
return script;
|
|
8
|
+
}
|
package/src/astro/client.ts
CHANGED
|
@@ -25,5 +25,7 @@ export * from "./filters.ts";
|
|
|
25
25
|
export * from "./filters-view.ts";
|
|
26
26
|
export * from "./meta-event.ts";
|
|
27
27
|
export { data, ref, refs } from "./ref.ts";
|
|
28
|
+
export * from "./snap-event.ts";
|
|
29
|
+
export * from "./tiktok-event.ts";
|
|
28
30
|
export * from "./umami-event.ts";
|
|
29
31
|
export * from "./youtube.ts";
|
package/src/astro/google-map.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/// <reference types="google.maps" />
|
|
2
2
|
import type { MapFrame } from "../map.ts";
|
|
3
|
+
import { appendScript } from "./append-script.ts";
|
|
3
4
|
import { reportDevError } from "./dev-log.ts";
|
|
4
5
|
import { element } from "./element.ts";
|
|
5
6
|
import { data } from "./ref.ts";
|
|
@@ -33,17 +34,19 @@ export function loadGoogleMaps(apiKey: string): Promise<typeof google.maps> {
|
|
|
33
34
|
loading ??= new Promise((resolve, reject) => {
|
|
34
35
|
(window as unknown as Record<string, () => void>)[CALLBACK] = () =>
|
|
35
36
|
resolve(google.maps);
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
const params = {
|
|
38
|
+
key: apiKey,
|
|
39
|
+
v: "weekly",
|
|
40
|
+
loading: "async",
|
|
41
|
+
callback: CALLBACK,
|
|
42
|
+
};
|
|
43
|
+
appendScript(
|
|
44
|
+
`https://maps.googleapis.com/maps/api/js?${new URLSearchParams(params)}`
|
|
45
|
+
).onerror = () => {
|
|
42
46
|
// So the next click tries again.
|
|
43
47
|
loading = undefined;
|
|
44
48
|
reject(new Error("the Maps JavaScript API failed to load"));
|
|
45
49
|
};
|
|
46
|
-
document.head.append(script);
|
|
47
50
|
});
|
|
48
51
|
return loading;
|
|
49
52
|
}
|
package/src/astro/meta-pixel.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appendScript } from "./append-script.ts";
|
|
1
2
|
import type { ConsentGated } from "./consent-gate.ts";
|
|
2
3
|
import { whenGranted } from "./consent-gate.ts";
|
|
3
4
|
import { element } from "./element.ts";
|
|
@@ -24,11 +25,15 @@ const SCRIPT = "https://connect.facebook.net/en_US/fbevents.js";
|
|
|
24
25
|
function defineFbq(): Fbq {
|
|
25
26
|
const global = window as unknown as PixelWindow;
|
|
26
27
|
if (global.fbq !== undefined) return global.fbq;
|
|
28
|
+
// `arguments` is the list of whatever a call passed, which every regular
|
|
29
|
+
// `function` gets without declaring it (an arrow function does not). The
|
|
30
|
+
// queue stores that object rather than an array because Meta's official
|
|
31
|
+
// snippet does: it is the shape Meta promises its script will replay.
|
|
27
32
|
const fbq = function () {
|
|
28
|
-
// biome-ignore lint/complexity/noArguments:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
else fbq.queue.push(
|
|
33
|
+
// biome-ignore lint/complexity/noArguments: see above.
|
|
34
|
+
const args = arguments;
|
|
35
|
+
if (fbq.callMethod) Reflect.apply(fbq.callMethod, fbq, args);
|
|
36
|
+
else fbq.queue.push(args);
|
|
32
37
|
} as Fbq;
|
|
33
38
|
fbq.push = fbq;
|
|
34
39
|
fbq.loaded = true;
|
|
@@ -52,10 +57,7 @@ export function metaPixel(pixelIds: readonly string[]): ConsentGated {
|
|
|
52
57
|
loaded = true;
|
|
53
58
|
for (const id of pixelIds) fbq("init", id);
|
|
54
59
|
fbq("track", "PageView");
|
|
55
|
-
|
|
56
|
-
script.async = true;
|
|
57
|
-
script.src = SCRIPT;
|
|
58
|
-
document.head.append(script);
|
|
60
|
+
appendScript(SCRIPT);
|
|
59
61
|
},
|
|
60
62
|
revoke: () => {
|
|
61
63
|
(window as unknown as PixelWindow).fbq?.("consent", "revoke");
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CurrencyCode } from "../money.ts";
|
|
2
|
+
import { readConsent } from "./consent.ts";
|
|
3
|
+
|
|
4
|
+
/** Snap's standard events that a website sends. */
|
|
5
|
+
export type SnapStandardEvent =
|
|
6
|
+
| "VIEW_CONTENT"
|
|
7
|
+
| "LIST_VIEW"
|
|
8
|
+
| "SEARCH"
|
|
9
|
+
| "ADD_CART"
|
|
10
|
+
| "ADD_TO_WISHLIST"
|
|
11
|
+
| "START_CHECKOUT"
|
|
12
|
+
| "ADD_BILLING"
|
|
13
|
+
| "PURCHASE"
|
|
14
|
+
| "SIGN_UP"
|
|
15
|
+
| "SUBSCRIBE"
|
|
16
|
+
| "LOGIN"
|
|
17
|
+
| "SAVE"
|
|
18
|
+
| "SHARE"
|
|
19
|
+
| "RESERVE"
|
|
20
|
+
| "START_TRIAL";
|
|
21
|
+
|
|
22
|
+
/** Snap's standard parameters. What the visitor chose, never who they are. */
|
|
23
|
+
export interface SnapEventData {
|
|
24
|
+
readonly price?: number;
|
|
25
|
+
readonly currency?: CurrencyCode;
|
|
26
|
+
readonly item_ids?: readonly string[];
|
|
27
|
+
readonly item_category?: string;
|
|
28
|
+
readonly number_items?: number;
|
|
29
|
+
readonly transaction_id?: string;
|
|
30
|
+
readonly search_string?: string;
|
|
31
|
+
readonly description?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `PURCHASE` needs a price and currency for Snap to report a return on spend. */
|
|
35
|
+
type SnapEventArgs<E extends SnapStandardEvent> = E extends "PURCHASE"
|
|
36
|
+
? [data: SnapEventData & { price: number; currency: CurrencyCode }]
|
|
37
|
+
: [data?: SnapEventData];
|
|
38
|
+
|
|
39
|
+
/** What `snap-pixel.ts` defines once marketing is granted. */
|
|
40
|
+
interface SnapWindow {
|
|
41
|
+
snaptr?: (...args: unknown[]) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Records a standard event with Snap's pixel — a lead sent, a booking made.
|
|
46
|
+
*
|
|
47
|
+
* Dropped unless the pixel loaded and marketing is still granted: Snap has no
|
|
48
|
+
* consent call of its own, so the check is here. An analytics call must never
|
|
49
|
+
* be the reason a page misbehaves, so a dropped event is silent.
|
|
50
|
+
*/
|
|
51
|
+
export function snapEvent<E extends SnapStandardEvent>(
|
|
52
|
+
event: E,
|
|
53
|
+
...[data]: SnapEventArgs<E>
|
|
54
|
+
): void {
|
|
55
|
+
if (readConsent()?.marketing !== "granted") return;
|
|
56
|
+
const snaptr = (window as unknown as SnapWindow).snaptr;
|
|
57
|
+
if (data === undefined) snaptr?.("track", event);
|
|
58
|
+
else snaptr?.("track", event, data);
|
|
59
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { appendScript } from "./append-script.ts";
|
|
2
|
+
import type { ConsentGated } from "./consent-gate.ts";
|
|
3
|
+
import { whenGranted } from "./consent-gate.ts";
|
|
4
|
+
import { element } from "./element.ts";
|
|
5
|
+
import { data } from "./ref.ts";
|
|
6
|
+
|
|
7
|
+
/** Snap's `snaptr`: a queue until `scevent.min.js` arrives and replays it. */
|
|
8
|
+
interface Snaptr {
|
|
9
|
+
(...args: unknown[]): void;
|
|
10
|
+
handleRequest?: (...args: unknown[]) => void;
|
|
11
|
+
queue: unknown[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PixelWindow {
|
|
15
|
+
snaptr?: Snaptr;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SCRIPT = "https://sc-static.net/scevent.min.js";
|
|
19
|
+
|
|
20
|
+
/** Snap's own base code, as it defines `snaptr` before the script loads. */
|
|
21
|
+
function defineSnaptr(): Snaptr {
|
|
22
|
+
const global = window as unknown as PixelWindow;
|
|
23
|
+
if (global.snaptr !== undefined) return global.snaptr;
|
|
24
|
+
// `arguments` is the list of whatever a call passed, which every regular
|
|
25
|
+
// `function` gets without declaring it (an arrow function does not). The
|
|
26
|
+
// queue stores that object rather than an array because Snap's official
|
|
27
|
+
// snippet does: it is the shape Snap promises its script will replay.
|
|
28
|
+
const snaptr = function () {
|
|
29
|
+
// biome-ignore lint/complexity/noArguments: see above.
|
|
30
|
+
const args = arguments;
|
|
31
|
+
if (snaptr.handleRequest)
|
|
32
|
+
Reflect.apply(snaptr.handleRequest, snaptr, args);
|
|
33
|
+
else snaptr.queue.push(args);
|
|
34
|
+
} as Snaptr;
|
|
35
|
+
snaptr.queue = [];
|
|
36
|
+
global.snaptr = snaptr;
|
|
37
|
+
return snaptr;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The pixel, loaded on the first grant. Snap has no consent call, so a
|
|
42
|
+
* withdrawal cannot pause what already loaded: `snapEvent` stops sending, and
|
|
43
|
+
* the next page does not load it.
|
|
44
|
+
*/
|
|
45
|
+
export function snapPixel(pixelIds: readonly string[]): ConsentGated {
|
|
46
|
+
let loaded = false;
|
|
47
|
+
return {
|
|
48
|
+
grant: () => {
|
|
49
|
+
if (loaded) return;
|
|
50
|
+
loaded = true;
|
|
51
|
+
const snaptr = defineSnaptr();
|
|
52
|
+
for (const id of pixelIds) snaptr("init", id);
|
|
53
|
+
snaptr("track", "PAGE_VIEW");
|
|
54
|
+
appendScript(SCRIPT);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Internal to `SnapPixel.astro`. */
|
|
60
|
+
export const snapPixelElement = element(
|
|
61
|
+
"atlas-snap-pixel",
|
|
62
|
+
({ root, signal }) => {
|
|
63
|
+
whenGranted(
|
|
64
|
+
"marketing",
|
|
65
|
+
snapPixel(JSON.parse(data(root, "pixel-ids"))),
|
|
66
|
+
signal
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CurrencyCode } from "../money.ts";
|
|
2
|
+
|
|
3
|
+
/** TikTok's standard events, by their current names (May 2025 onwards). */
|
|
4
|
+
export type TikTokStandardEvent =
|
|
5
|
+
| "AddPaymentInfo"
|
|
6
|
+
| "AddToCart"
|
|
7
|
+
| "AddToWishlist"
|
|
8
|
+
| "CompleteRegistration"
|
|
9
|
+
| "Contact"
|
|
10
|
+
| "Download"
|
|
11
|
+
| "InitiateCheckout"
|
|
12
|
+
| "Lead"
|
|
13
|
+
| "Login"
|
|
14
|
+
| "Purchase"
|
|
15
|
+
| "Search"
|
|
16
|
+
| "StartTrial"
|
|
17
|
+
| "SubmitApplication"
|
|
18
|
+
| "Subscribe"
|
|
19
|
+
| "ViewContent";
|
|
20
|
+
|
|
21
|
+
/** TikTok's standard parameters. What the visitor chose, never who they are. */
|
|
22
|
+
export interface TikTokEventData {
|
|
23
|
+
readonly value?: number;
|
|
24
|
+
readonly currency?: CurrencyCode;
|
|
25
|
+
readonly content_id?: string;
|
|
26
|
+
readonly content_type?: "product" | "product_group";
|
|
27
|
+
readonly content_name?: string;
|
|
28
|
+
readonly content_category?: string;
|
|
29
|
+
readonly quantity?: number;
|
|
30
|
+
readonly query?: string;
|
|
31
|
+
readonly description?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `Purchase` needs a value and currency to be worth anything to TikTok. */
|
|
35
|
+
type TikTokEventArgs<E extends TikTokStandardEvent> = E extends "Purchase"
|
|
36
|
+
? [data: TikTokEventData & { value: number; currency: CurrencyCode }]
|
|
37
|
+
: [data?: TikTokEventData];
|
|
38
|
+
|
|
39
|
+
/** What `tiktok-pixel.ts` defines once marketing is granted. */
|
|
40
|
+
interface TikTokWindow {
|
|
41
|
+
ttq?: { track(event: string, data?: object): void };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Records a standard event with TikTok's pixel — a lead sent, a booking made.
|
|
46
|
+
*
|
|
47
|
+
* `ttq` exists only once the visitor granted marketing and the pixel loaded.
|
|
48
|
+
* Before that, without consent, or where TikTok is not configured, the event is
|
|
49
|
+
* dropped: no consent means nothing to send, and an analytics call must never
|
|
50
|
+
* be the reason a page misbehaves.
|
|
51
|
+
*/
|
|
52
|
+
export function tiktokEvent<E extends TikTokStandardEvent>(
|
|
53
|
+
event: E,
|
|
54
|
+
...[data]: TikTokEventArgs<E>
|
|
55
|
+
): void {
|
|
56
|
+
const ttq = (window as unknown as TikTokWindow).ttq;
|
|
57
|
+
if (data === undefined) ttq?.track(event);
|
|
58
|
+
else ttq?.track(event, data);
|
|
59
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { appendScript } from "./append-script.ts";
|
|
2
|
+
import type { ConsentGated } from "./consent-gate.ts";
|
|
3
|
+
import { whenGranted } from "./consent-gate.ts";
|
|
4
|
+
import { element } from "./element.ts";
|
|
5
|
+
import { data } from "./ref.ts";
|
|
6
|
+
|
|
7
|
+
/** TikTok's `ttq`: a queue of calls until `events.js` arrives and replays it. */
|
|
8
|
+
interface Ttq extends Array<unknown> {
|
|
9
|
+
methods: readonly string[];
|
|
10
|
+
setAndDefer(target: unknown[], method: string): void;
|
|
11
|
+
instance(id: string): unknown[];
|
|
12
|
+
load(id: string, options?: object): void;
|
|
13
|
+
_i: Record<string, unknown[] & { _u?: string }>;
|
|
14
|
+
_t: Record<string, number>;
|
|
15
|
+
_o: Record<string, object>;
|
|
16
|
+
[method: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PixelWindow {
|
|
20
|
+
ttq?: Ttq;
|
|
21
|
+
TiktokAnalyticsObject?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SCRIPT = "https://analytics.tiktok.com/i18n/pixel/events.js";
|
|
25
|
+
|
|
26
|
+
const METHODS = [
|
|
27
|
+
"page",
|
|
28
|
+
"track",
|
|
29
|
+
"identify",
|
|
30
|
+
"instances",
|
|
31
|
+
"debug",
|
|
32
|
+
"on",
|
|
33
|
+
"off",
|
|
34
|
+
"once",
|
|
35
|
+
"ready",
|
|
36
|
+
"alias",
|
|
37
|
+
"group",
|
|
38
|
+
"enableCookie",
|
|
39
|
+
"disableCookie",
|
|
40
|
+
"holdConsent",
|
|
41
|
+
"revokeConsent",
|
|
42
|
+
"grantConsent",
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
/** TikTok's own base code, as it defines `ttq` before the script loads. */
|
|
46
|
+
function defineTtq(): Ttq {
|
|
47
|
+
const global = window as unknown as PixelWindow;
|
|
48
|
+
if (global.ttq !== undefined) return global.ttq;
|
|
49
|
+
global.TiktokAnalyticsObject = "ttq";
|
|
50
|
+
const ttq = [] as unknown as Ttq;
|
|
51
|
+
ttq.methods = METHODS;
|
|
52
|
+
ttq.setAndDefer = (target, method) => {
|
|
53
|
+
(target as unknown as Record<string, unknown>)[method] = (
|
|
54
|
+
...args: unknown[]
|
|
55
|
+
) => target.push([method, ...args]);
|
|
56
|
+
};
|
|
57
|
+
for (const method of METHODS) ttq.setAndDefer(ttq, method);
|
|
58
|
+
ttq.instance = (id) => {
|
|
59
|
+
const queue = ttq._i?.[id] ?? [];
|
|
60
|
+
for (const method of METHODS) ttq.setAndDefer(queue, method);
|
|
61
|
+
return queue;
|
|
62
|
+
};
|
|
63
|
+
ttq.load = (id, options = {}) => {
|
|
64
|
+
ttq._i ??= {};
|
|
65
|
+
ttq._i[id] = Object.assign([], { _u: SCRIPT });
|
|
66
|
+
ttq._t ??= {};
|
|
67
|
+
ttq._t[id] = Date.now();
|
|
68
|
+
ttq._o ??= {};
|
|
69
|
+
ttq._o[id] = options;
|
|
70
|
+
appendScript(
|
|
71
|
+
`${SCRIPT}?${new URLSearchParams({ sdkid: id, lib: "ttq" })}`
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
global.ttq = ttq;
|
|
75
|
+
return ttq;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const call = (ttq: Ttq, method: (typeof METHODS)[number], ...args: unknown[]) =>
|
|
79
|
+
(ttq[method] as (...args: unknown[]) => void)(...args);
|
|
80
|
+
|
|
81
|
+
/** The pixel: loaded on the first grant, paused and resumed by later answers. */
|
|
82
|
+
export function tiktokPixel(pixelIds: readonly string[]): ConsentGated {
|
|
83
|
+
let loaded = false;
|
|
84
|
+
return {
|
|
85
|
+
grant: () => {
|
|
86
|
+
const ttq = defineTtq();
|
|
87
|
+
// Also lifts the hold of TikTok's Cookie Consent Mode, where a
|
|
88
|
+
// pixel has it on in Events Manager.
|
|
89
|
+
call(ttq, "grantConsent");
|
|
90
|
+
if (loaded) return;
|
|
91
|
+
loaded = true;
|
|
92
|
+
for (const id of pixelIds) ttq.load(id);
|
|
93
|
+
call(ttq, "page");
|
|
94
|
+
},
|
|
95
|
+
revoke: () => {
|
|
96
|
+
const ttq = (window as unknown as PixelWindow).ttq;
|
|
97
|
+
if (ttq !== undefined) call(ttq, "revokeConsent");
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Internal to `TikTokPixel.astro`. */
|
|
103
|
+
export const tiktokPixelElement = element(
|
|
104
|
+
"atlas-tiktok-pixel",
|
|
105
|
+
({ root, signal }) => {
|
|
106
|
+
whenGranted(
|
|
107
|
+
"marketing",
|
|
108
|
+
tiktokPixel(JSON.parse(data(root, "pixel-ids"))),
|
|
109
|
+
signal
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
);
|