diff --git a/apps/html/conversion-tracking.html b/apps/html/conversion-tracking.html new file mode 100644 index 0000000..2b71d0b --- /dev/null +++ b/apps/html/conversion-tracking.html @@ -0,0 +1,66 @@ + + + + + + + Client Conversion Tracking + + + + + + +
+

Client Conversion Tracking

+ + + +
+ + diff --git a/apps/nextjs/app/conversion-tracking/page-client.tsx b/apps/nextjs/app/conversion-tracking/page-client.tsx new file mode 100644 index 0000000..f9019bf --- /dev/null +++ b/apps/nextjs/app/conversion-tracking/page-client.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useAnalytics } from '@dub/analytics/react'; + +export function ConversionTrackingPageClient() { + const { trackLead, trackSale } = useAnalytics(); + + const handleTrackLead = () => { + trackLead({ + eventName: 'Account created', + customerExternalId: '1234567890', + }); + }; + + const handleTrackSale = () => { + trackSale({ + eventName: 'Purchase completed', + customerExternalId: 'CXvG5QOLi8QKBA2jYmDh', + amount: 5000, // defaults to usd cents, use `currency` prop to specify a different currency + }); + }; + + return ( +
+ + +
+ ); +} diff --git a/apps/nextjs/app/conversion-tracking/page.tsx b/apps/nextjs/app/conversion-tracking/page.tsx new file mode 100644 index 0000000..03e0679 --- /dev/null +++ b/apps/nextjs/app/conversion-tracking/page.tsx @@ -0,0 +1,5 @@ +import { ConversionTrackingPageClient } from './page-client'; + +export default function ConversionTrackingPage() { + return ; +} diff --git a/packages/script/build.js b/packages/script/build.js index 43785b3..9232a35 100644 --- a/packages/script/build.js +++ b/packages/script/build.js @@ -24,9 +24,9 @@ fs.copyFileSync( path.join(__dirname, 'dist/_redirects'), ); -// Build all variants +// Build all variants (8 total combinations of 3 extensions) Promise.all([ - // Base script + // 1. Base script (no extensions) esbuild.build({ ...baseConfig, stdin: { @@ -37,7 +37,7 @@ Promise.all([ outfile: 'dist/analytics/script.js', }), - // Site visit tracking + // 2. Site visit only esbuild.build({ ...baseConfig, stdin: { @@ -48,7 +48,7 @@ Promise.all([ outfile: 'dist/analytics/script.site-visit.js', }), - // Outbound domains tracking + // 3. Outbound domains only esbuild.build({ ...baseConfig, stdin: { @@ -62,7 +62,21 @@ Promise.all([ outfile: 'dist/analytics/script.outbound-domains.js', }), - // Complete script with concatenated feature names + // 4. Conversion tracking only + esbuild.build({ + ...baseConfig, + stdin: { + contents: combineFiles([ + 'src/base.js', + 'src/extensions/conversion-tracking.js', + ]), + resolveDir: __dirname, + sourcefile: 'combined.js', + }, + outfile: 'dist/analytics/script.conversion-tracking.js', + }), + + // 5. Site visit + Outbound domains esbuild.build({ ...baseConfig, stdin: { @@ -76,4 +90,51 @@ Promise.all([ }, outfile: 'dist/analytics/script.site-visit.outbound-domains.js', }), + + // 6. Site visit + Conversion tracking + esbuild.build({ + ...baseConfig, + stdin: { + contents: combineFiles([ + 'src/base.js', + 'src/extensions/site-visit.js', + 'src/extensions/conversion-tracking.js', + ]), + resolveDir: __dirname, + sourcefile: 'combined.js', + }, + outfile: 'dist/analytics/script.site-visit.conversion-tracking.js', + }), + + // 7. Outbound domains + Conversion tracking + esbuild.build({ + ...baseConfig, + stdin: { + contents: combineFiles([ + 'src/base.js', + 'src/extensions/outbound-domains.js', + 'src/extensions/conversion-tracking.js', + ]), + resolveDir: __dirname, + sourcefile: 'combined.js', + }, + outfile: 'dist/analytics/script.outbound-domains.conversion-tracking.js', + }), + + // 8. All extensions combined + esbuild.build({ + ...baseConfig, + stdin: { + contents: combineFiles([ + 'src/base.js', + 'src/extensions/site-visit.js', + 'src/extensions/outbound-domains.js', + 'src/extensions/conversion-tracking.js', + ]), + resolveDir: __dirname, + sourcefile: 'combined.js', + }, + outfile: + 'dist/analytics/script.site-visit.outbound-domains.conversion-tracking.js', + }), ]).catch(() => process.exit(1)); diff --git a/packages/script/package.json b/packages/script/package.json index db30093..fbcee0a 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -1,12 +1,16 @@ { "name": "@dub/analytics-script", - "version": "0.0.29", + "version": "0.0.30", "main": "src/index.js", "files": [ "dist/analytics/script.js", "dist/analytics/script.site-visit.js", "dist/analytics/script.outbound-domains.js", - "dist/analytics/script.site-visit.outbound-domains.js" + "dist/analytics/script.conversion-tracking.js", + "dist/analytics/script.site-visit.outbound-domains.js", + "dist/analytics/script.site-visit.conversion-tracking.js", + "dist/analytics/script.outbound-domains.conversion-tracking.js", + "dist/analytics/script.site-visit.outbound-domains.conversion-tracking.js" ], "scripts": { "prebuild": "mkdir -p dist/analytics", diff --git a/packages/script/src/base.js b/packages/script/src/base.js index cec19f2..6cfc9c1 100644 --- a/packages/script/src/base.js +++ b/packages/script/src/base.js @@ -9,6 +9,7 @@ // Common script attributes const API_HOST = script.getAttribute('data-api-host') || 'https://api.dub.co'; + const PUBLISHABLE_KEY = script.getAttribute('data-publishable-key'); const COOKIE_OPTIONS = (() => { const defaultOptions = { domain: @@ -271,6 +272,8 @@ p: QUERY_PARAM, // was QUERY_PARAM v: QUERY_PARAM_VALUE, // was QUERY_PARAM_VALUE n: DOMAINS_CONFIG, // was DOMAINS_CONFIG + k: PUBLISHABLE_KEY, + qm: queueManager, }; // Initialize diff --git a/packages/script/src/extensions/conversion-tracking.js b/packages/script/src/extensions/conversion-tracking.js new file mode 100644 index 0000000..b8efba6 --- /dev/null +++ b/packages/script/src/extensions/conversion-tracking.js @@ -0,0 +1,104 @@ +const initConversionTracking = () => { + const { + a: API_HOST, + k: PUBLISHABLE_KEY, + c: cookieManager, + i: DUB_ID_VAR, + } = window._dubAnalytics || {}; + + if (!API_HOST) { + console.warn('[dubAnalytics] Missing API_HOST'); + return; + } + + if (!PUBLISHABLE_KEY) { + console.warn('[dubAnalytics] Missing PUBLISHABLE_KEY'); + return; + } + + // Track lead conversion + const trackLead = async (input) => { + const clickId = cookieManager?.get(DUB_ID_VAR); + + const requestBody = { + ...(clickId && { clickId }), + ...input, + }; + + const response = await fetch(`${API_HOST}/track/lead/client`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${PUBLISHABLE_KEY}`, + }, + body: JSON.stringify(requestBody), + }); + + const result = await response.json(); + + if (!response.ok) { + console.error('[dubAnalytics] trackLead failed', result.error); + } + + return result; + }; + + // Track sale conversion + const trackSale = async (input) => { + const response = await fetch(`${API_HOST}/track/sale/client`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${PUBLISHABLE_KEY}`, + }, + body: JSON.stringify(input), + }); + + const result = await response.json(); + + if (!response.ok) { + console.error('[dubAnalytics] trackSale failed', result.error); + } + + return result; + }; + + // Add methods to the global dubAnalytics object for direct calls + if (window.dubAnalytics) { + window.dubAnalytics.trackLead = function (...args) { + trackLead(...args); + }; + + window.dubAnalytics.trackSale = function (...args) { + trackSale(...args); + }; + } + + // Process any existing queued conversion events + if (window._dubAnalytics && window._dubAnalytics.qm) { + const queueManager = window._dubAnalytics.qm; + const existingQueue = queueManager.queue || []; + + const remainingQueue = existingQueue.filter(([method, ...args]) => { + if (method === 'trackLead') { + trackLead(...args); + return false; + } else if (method === 'trackSale') { + trackSale(...args); + return false; + } + + return true; + }); + + // Update the queue with remaining items + queueManager.queue = remainingQueue; + } +}; + +// Run when base script is ready +if (window._dubAnalytics) { + initConversionTracking(); +} else { + window.addEventListener('load', initConversionTracking); +} diff --git a/packages/web/package.json b/packages/web/package.json index 3506ee1..34ad9f9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@dub/analytics", - "version": "0.0.29", + "version": "0.0.30", "description": "", "keywords": [ "analytics", diff --git a/packages/web/src/generic.ts b/packages/web/src/generic.ts index ac5c2be..a7aa44b 100644 --- a/packages/web/src/generic.ts +++ b/packages/web/src/generic.ts @@ -19,7 +19,7 @@ function inject(props: AnalyticsProps): void { (w[da].q = w[da].q || []).push(arguments); }; - ['trackClick'].forEach(function (m) { + ['trackClick', 'trackLead', 'trackSale'].forEach(function (m) { w[da][m] = function () { w[da](m, ...Array.from(arguments)); }; @@ -32,6 +32,7 @@ function inject(props: AnalyticsProps): void { if (props.domainsConfig?.site) features.push('site-visit'); if (props.domainsConfig?.outbound) features.push('outbound-domains'); + if (props.publishableKey) features.push('conversion-tracking'); const src = props.scriptProps?.src || @@ -51,6 +52,10 @@ function inject(props: AnalyticsProps): void { script.setAttribute('data-api-host', props.apiHost); } + if (props.publishableKey) { + script.setAttribute('data-publishable-key', props.publishableKey); + } + if (props.domainsConfig) { script.setAttribute('data-domains', JSON.stringify(props.domainsConfig)); } diff --git a/packages/web/src/react.tsx b/packages/web/src/react.tsx index ac989e6..b4d8e92 100644 --- a/packages/web/src/react.tsx +++ b/packages/web/src/react.tsx @@ -1,6 +1,12 @@ import { useEffect } from 'react'; import { inject } from './generic'; -import type { AnalyticsProps, Discount, Partner } from './types'; +import type { + AnalyticsProps, + Discount, + Partner, + TrackLeadInput, + TrackSaleInput, +} from './types'; import { useAnalytics } from './use-analytics'; /** @@ -28,4 +34,10 @@ function Analytics(props: AnalyticsProps): null { } export { Analytics, useAnalytics }; -export type { AnalyticsProps, Partner, Discount }; +export type { + AnalyticsProps, + Partner, + Discount, + TrackLeadInput, + TrackSaleInput, +}; diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index e4de099..8076f02 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -1,5 +1,3 @@ -export type AllowedPropertyValues = string | number | boolean | null; - export interface AnalyticsProps { /** * The API endpoint to send analytics data to. @@ -7,6 +5,12 @@ export interface AnalyticsProps { */ apiHost?: string; + /** + * The publishable key for client conversion tracking + * @example 'dub_pk_BgyBCEJCPCGN3RN7oieLVHRs' + */ + publishableKey?: string; + /** * This is a JSON object that configures the domains that Dub will track. * @@ -161,6 +165,27 @@ export interface TrackClickInput { key: string; } +export interface TrackLeadInput { + clickId?: string; // falls back to dub_id cookie + eventName: string; + customerExternalId: string; + customerName?: string; + customerEmail?: string; + customerAvatar?: string; + mode?: string; + metadata?: Record; +} + +export interface TrackSaleInput { + eventName: string; + customerExternalId: string; + paymentProcessor?: string; + amount: number; + invoiceId?: string; + currency?: string; + metadata?: Record; +} + export interface Partner { id: string; name: string; diff --git a/packages/web/src/use-analytics.ts b/packages/web/src/use-analytics.ts index 61fbcbc..cb979a3 100644 --- a/packages/web/src/use-analytics.ts +++ b/packages/web/src/use-analytics.ts @@ -1,5 +1,11 @@ import { useCallback, useEffect, useState } from 'react'; -import type { Discount, Partner, TrackClickInput } from './types'; +import type { + Discount, + Partner, + TrackClickInput, + TrackLeadInput, + TrackSaleInput, +} from './types'; import { isDubAnalyticsReady } from './utils'; interface PartnerData { @@ -12,13 +18,15 @@ declare global { DubAnalytics: PartnerData; dubAnalytics: ((event: 'ready', callback: () => void) => void) & { trackClick: (event: TrackClickInput) => void; + trackLead: (event: TrackLeadInput) => void; + trackSale: (event: TrackSaleInput) => void; }; } } /** * Hook to access Dub Web Analytics data including partner and discount information. - * @returns Object containing partner data, and discount information. + * @returns Object containing partner data, discount information, and tracking methods. * ```js * import { useAnalytics } from '@dub/analytics/react'; * @@ -59,6 +67,22 @@ export function useAnalytics() { window.dubAnalytics.trackClick(event); }, []); + const trackLead = useCallback((event: TrackLeadInput) => { + if (!isDubAnalyticsReady()) { + return; + } + + window.dubAnalytics.trackLead(event); + }, []); + + const trackSale = useCallback((event: TrackSaleInput) => { + if (!isDubAnalyticsReady()) { + return; + } + + window.dubAnalytics.trackSale(event); + }, []); + useEffect(() => { initialize(); }, [initialize]); @@ -66,5 +90,7 @@ export function useAnalytics() { return { ...data, trackClick, + trackLead, + trackSale, }; }