Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"dev": "nuxt dev playground",
"dev:ssl": "nuxt dev playground --https",
"dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures",
"prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics",
"prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/tiktok-pixel && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics",
"typecheck": "nuxt typecheck",
"release": "pnpm build && bumpp -r --output=CHANGELOG.md",
"lint": "eslint .",
Expand Down
21 changes: 18 additions & 3 deletions packages/script/src/registry-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -1015,14 +1015,29 @@
"code": "interface TrackOptions {\n /** Used to deduplicate events sent from both the browser Pixel and the server-side Events API. */\n event_id?: string\n /**\n * Sandbox test-event identifier. When set, events route to TikTok's Test Events panel\n * without affecting production reporting.\n */\n test_event_code?: string\n [key: string]: any\n}"
},
{
"name": "TtqFns",
"name": "TtqInstance",
"kind": "interface",
"code": "export interface TtqInstance {\n /** Track a page view. */\n page: () => void\n /** Track a standard or custom conversion event. */\n track: (event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void\n /** Associate advanced-matching identifiers with the current user. */\n identify: (properties: IdentifyProperties) => void\n /** Opt user in to tracking. Queued before the script loads; live once `events.js` binds. */\n grantConsent: () => void\n /** Opt user out of tracking. Queued before the script loads; live once `events.js` binds. */\n revokeConsent: () => void\n /** Defer consent until an explicit grant/revoke. Queued before the script loads; live once `events.js` binds. */\n holdConsent: () => void\n enableCookie: () => void\n disableCookie: () => void\n}"
},
{
"name": "TtqCallable",
"kind": "type",
"code": "type TtqFns\n = ((cmd: 'track', event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void)\n & ((cmd: 'page') => void)\n & ((cmd: 'identify', properties: IdentifyProperties) => void)\n & ((cmd: (string & {}), ...args: any[]) => void)"
"code": "type TtqCallable\n = ((cmd: 'track', event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void)\n & ((cmd: 'page') => void)\n & ((cmd: 'identify', properties: IdentifyProperties) => void)\n & ((cmd: (string & {}), ...args: any[]) => void)"
},
{
"name": "Ttq",
"kind": "type",
"code": "export type Ttq = TtqCallable & TtqInstance & {\n /** Resolve the per-pixel method bag for a specific pixel id. */\n instance: (id: string) => TtqInstance\n}"
},
{
"name": "TikTokPixelApi",
"kind": "interface",
"code": "export interface TikTokPixelApi {\n ttq: TtqFns & {\n push: TtqFns\n loaded: boolean\n queue: any[]\n /** Opt user in to tracking. Queued before the script loads; live once `events.js` binds. */\n grantConsent: () => void\n /** Opt user out of tracking. Queued before the script loads; live once `events.js` binds. */\n revokeConsent: () => void\n /** Defer consent until an explicit grant/revoke. Queued before the script loads; live once `events.js` binds. */\n holdConsent: () => void\n }\n}"
"code": "export interface TikTokPixelApi {\n ttq: Ttq\n}"
},
{
"name": "TtqArray",
"kind": "type",
"code": "type TtqArray = TtqInstance & Array<any[]> & {\n methods: string[]\n setAndDefer: (target: any, method: string) => void\n instance: (id: string) => TtqInstance\n _i?: Record<string, any[]>\n _t?: Record<string, number>\n _o?: Record<string, any>\n}"
},
{
"name": "TikTokPixelOptions",
Expand Down
156 changes: 111 additions & 45 deletions packages/script/src/runtime/registry/tiktok-pixel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,28 +70,70 @@ interface TrackOptions {
[key: string]: any
}

type TtqFns
/**
* The deferred methods TikTok's base code installs on `ttq`. Calling one before
* `events.js` loads pushes a `[method, ...args]` tuple onto the queue; afterwards
* `events.js` rebinds them to live implementations.
*/
export interface TtqInstance {
/** Track a page view. */
page: () => void
/** Track a standard or custom conversion event. */
track: (event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void
/** Associate advanced-matching identifiers with the current user. */
identify: (properties: IdentifyProperties) => void
/** Opt user in to tracking. Queued before the script loads; live once `events.js` binds. */
grantConsent: () => void
/** Opt user out of tracking. Queued before the script loads; live once `events.js` binds. */
revokeConsent: () => void
/** Defer consent until an explicit grant/revoke. Queued before the script loads; live once `events.js` binds. */
holdConsent: () => void
enableCookie: () => void
disableCookie: () => void
}

/**
* Legacy callable signature. Pre-1.2 `useScriptTikTokPixel` exposed `ttq` as an
* `fbq`-style callable (`ttq('page')`, `ttq('track', …)`); the adapter keeps it
* working alongside the method form.
*/
type TtqCallable
= ((cmd: 'track', event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void)
& ((cmd: 'page') => void)
& ((cmd: 'identify', properties: IdentifyProperties) => void)
& ((cmd: (string & {}), ...args: any[]) => void)

/**
* The public `ttq` returned by the composable: a callable adapter (legacy form)
* that also carries the deferred methods (`ttq.page()`, `ttq.track(…)`). The
* adapter forwards to `window.ttq`, which is TikTok's real array protocol.
*/
export type Ttq = TtqCallable & TtqInstance & {
/** Resolve the per-pixel method bag for a specific pixel id. */
instance: (id: string) => TtqInstance
}

export interface TikTokPixelApi {
ttq: TtqFns & {
push: TtqFns
loaded: boolean
queue: any[]
/** Opt user in to tracking. Queued before the script loads; live once `events.js` binds. */
grantConsent: () => void
/** Opt user out of tracking. Queued before the script loads; live once `events.js` binds. */
revokeConsent: () => void
/** Defer consent until an explicit grant/revoke. Queued before the script loads; live once `events.js` binds. */
holdConsent: () => void
}
ttq: Ttq
}

/**
* TikTok's `window.ttq` is an Array, not a callable: `events.js` reads it as a
* queue of `[method, ...args]` tuples and replays them, with the deferred
* methods installed as own properties on the array.
*/
type TtqArray = TtqInstance & Array<any[]> & {
methods: string[]
setAndDefer: (target: any, method: string) => void
instance: (id: string) => TtqInstance
_i?: Record<string, any[]>
_t?: Record<string, number>
_o?: Record<string, any>
}

declare global {
interface Window extends TikTokPixelApi {
interface Window {
ttq: TtqArray
TiktokAnalyticsObject: string
}
}
Expand All @@ -107,6 +149,9 @@ export function tiktokPixelSrc(region?: 'global' | 'us'): string {
: 'https://analytics.tiktok.com/i18n/pixel/events.js'
}

// The deferred-method names TikTok's base code defers onto `ttq`.
const TTQ_METHODS = ['page', 'track', 'identify', 'instances', 'debug', 'on', 'off', 'once', 'ready', 'alias', 'group', 'enableCookie', 'disableCookie', 'holdConsent', 'revokeConsent', 'grantConsent']

const SHA256_HEX = /^[a-f0-9]{64}$/i

function warnUnhashedIdentify(props: Record<string, unknown>): void {
Expand All @@ -120,6 +165,23 @@ function warnUnhashedIdentify(props: Record<string, unknown>): void {
}
}

/**
* Build the callable adapter returned to consumers. It dispatches to the live
* `window.ttq` array on every call, so it tracks `events.js` rebinding the
* deferred methods. Supports both `ttq('page')` (legacy) and `ttq.page()`.
*/
function createTtqAdapter(): Ttq {
const dispatch = (method: string, ...args: any[]) => {
if (import.meta.dev && method === 'identify' && args[0])
warnUnhashedIdentify(args[0])
return (window.ttq as any)[method]?.(...args)
}
const adapter = ((method: string, ...args: any[]) => dispatch(method, ...args)) as Ttq
for (const method of [...TTQ_METHODS, 'instance'])
(adapter as any)[method] = (...args: any[]) => dispatch(method, ...args)
return adapter
}

export interface TikTokPixelConsent {
/** Call `ttq.grantConsent()`. */
grant: () => void
Expand All @@ -141,47 +203,51 @@ export function useScriptTikTokPixel<T extends TikTokPixelApi>(_options?: TikTok
schema: import.meta.dev ? TikTokPixelOptions : undefined,
scriptOptions: {
use() {
return { ttq: window.ttq }
return { ttq: createTtqAdapter() }
},
},
// TikTok's `events.js` consumes the official array-based snippet protocol:
// `window.ttq` is an Array of `[method, ...args]` tuples that `events.js`
// drains once loaded. (It does NOT understand the Facebook `fbq` callable
// protocol β€” using that shape silently drops every browser Pixel event.)
clientInit: import.meta.server
? undefined
: () => {
window.TiktokAnalyticsObject = 'ttq'
const ttq: TikTokPixelApi['ttq'] = window.ttq = function (...params: any[]) {
if (import.meta.dev && params[0] === 'identify' && params[1])
warnUnhashedIdentify(params[1])
// @ts-expect-error untyped
if (ttq.callMethod) {
// @ts-expect-error untyped
ttq.callMethod(...params)
}
else {
ttq.queue.push(params)
}
} as any
ttq.push = ttq
ttq.loaded = true
ttq.queue = []
// Queue consent stubs so pre-load `ttq.grantConsent()` / `ttq.revokeConsent()` work.
// The real events.js replaces these with live bindings once loaded.
const consentMethods = ['grantConsent', 'revokeConsent', 'holdConsent'] as const
for (const name of consentMethods) {
;(ttq as any)[name] = function (...params: any[]) {
ttq.queue.push([name, ...params])
const ttq = (window.ttq = window.ttq || ([] as unknown as TtqArray))
ttq.methods = TTQ_METHODS
ttq.setAndDefer = function (target: any, method: string) {
target[method] = function (...args: any[]) {
target.push([method, ...args])
}
}
if (options?.defaultConsent === 'granted')
ttq.grantConsent()
else if (options?.defaultConsent === 'denied')
ttq.revokeConsent()
else if (options?.defaultConsent === 'hold')
ttq.holdConsent()
for (const method of ttq.methods)
ttq.setAndDefer(ttq, method)
ttq.instance = function (id: string) {
const bag = ttq._i?.[id] || []
for (const method of ttq.methods)
ttq.setAndDefer(bag, method)
return bag as unknown as TtqInstance
}
if (options?.id) {
ttq('init', options.id)
if (options?.trackPageView !== false) {
ttq('page')
}
// Per-pixel scaffolding read by `events.js` (equivalent to the base
// code's `ttq.load(id)`, minus the <script> insertion that the
// registry script tag already handles).
ttq._i = ttq._i || {}
ttq._i[options.id] = ttq._i[options.id] || []
;(ttq._i[options.id] as any)._u = tiktokPixelSrc(options?.region)
ttq._t = ttq._t || {}
ttq._t[options.id] = Date.now()
ttq._o = ttq._o || {}
ttq._o[options.id] = {}
if (options?.defaultConsent === 'granted')
ttq.grantConsent()
else if (options?.defaultConsent === 'denied')
ttq.revokeConsent()
else if (options?.defaultConsent === 'hold')
ttq.holdConsent()
if (options?.trackPageView !== false)
ttq.page()
}
},
}), _options) as UseScriptContext<T, TikTokPixelConsent>
Expand Down
Loading
Loading