Skip to content

Commit 05bb6f8

Browse files
committed
Merge branch 'main' into partner-discount-dx
2 parents 62eb291 + 2ac29d4 commit 05bb6f8

File tree

5 files changed

+247
-29
lines changed

5 files changed

+247
-29
lines changed

apps/nextjs/app/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export default function RootLayout({
2525
domainsConfig={{
2626
refer: 'getacme.link',
2727
site: 'getacme.link',
28+
outbound: 'example.com,other.com,sub.example.com',
2829
}}
2930
scriptProps={{
3031
src: DUB_ANALYTICS_SCRIPT_URL,

apps/nextjs/app/outbound/page.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export default function Outbound() {
2+
return (
3+
<div>
4+
<a href="https://example.com">Example Link</a>
5+
<a href="https://other.com?foo=bar">Other Link</a>
6+
<a href="https://unrelated.com">Unrelated Link</a>
7+
8+
<iframe src="https://example.com/embed"></iframe>
9+
<iframe src="https://www.example.com/embed"></iframe>
10+
11+
<a href="https://getacme.link/about">Internal Link</a>
12+
<div id="container"></div>
13+
14+
<a href="https://www.example.com">WWW Link</a>
15+
<a href="https://sub.example.com">Subdomain Link</a>
16+
<a href="https://other.example.com">Other Subdomain Link</a>
17+
<a href="https://www.sub.example.com">WWW Subdomain Link</a>
18+
</div>
19+
);
20+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { DUB_ANALYTICS_SCRIPT_URL } from '@/app/constants';
2+
import { test, expect } from '@playwright/test';
3+
4+
declare global {
5+
interface Window {
6+
_dubAnalytics: any;
7+
}
8+
}
9+
10+
test.describe('Outbound domains tracking', () => {
11+
test('should add tracking parameters to outbound links', async ({ page }) => {
12+
await page.goto('/outbound?dub_id=test-click-id');
13+
14+
await page.waitForFunction(() => window._dubAnalytics !== undefined);
15+
16+
await page.waitForTimeout(1000);
17+
18+
// Check that outbound links have tracking parameters
19+
const exampleLink = await page.$('a[href*="example.com"]');
20+
const otherLink = await page.$('a[href*="other.com"]');
21+
const unrelatedLink = await page.$('a[href*="unrelated.com"]');
22+
23+
const exampleHref = await exampleLink?.getAttribute('href');
24+
const otherHref = await otherLink?.getAttribute('href');
25+
const unrelatedHref = await unrelatedLink?.getAttribute('href');
26+
27+
expect(exampleHref).toContain('dub_id=test-click-id');
28+
expect(otherHref).toContain('dub_id=test-click-id');
29+
expect(unrelatedHref).not.toContain('dub_id=test-click-id');
30+
});
31+
32+
test('should handle iframe src attributes', async ({ page }) => {
33+
await page.goto('/outbound?dub_id=test-click-id');
34+
35+
await page.waitForFunction(() => window._dubAnalytics !== undefined);
36+
37+
await page.waitForTimeout(2000);
38+
39+
const iframe = await page.$('iframe');
40+
const iframeSrc = await iframe?.getAttribute('src');
41+
expect(iframeSrc).toContain('dub_id=test-click-id');
42+
});
43+
44+
test('should not add tracking to links on the same domain', async ({
45+
page,
46+
}) => {
47+
await page.goto('/outbound?dub_id=test-click-id');
48+
49+
await page.waitForFunction(() => window._dubAnalytics !== undefined);
50+
51+
await page.waitForTimeout(2000);
52+
53+
const internalLink = await page.$('a[href*="getacme.link"]');
54+
const externalLink = await page.$('a[href*="example.com"]');
55+
56+
const internalHref = await internalLink?.getAttribute('href');
57+
const externalHref = await externalLink?.getAttribute('href');
58+
59+
expect(internalHref).not.toContain('dub_id=test-click-id');
60+
expect(externalHref).toContain('dub_id=test-click-id');
61+
});
62+
63+
// TODO: Fix this test
64+
test.skip('should handle dynamically added links', async ({ page }) => {
65+
await page.goto('/outbound?dub_id=test-click-id');
66+
67+
await page.waitForFunction(() => window._dubAnalytics !== undefined);
68+
69+
// Add a link dynamically
70+
await page.evaluate(() => {
71+
const container = document.getElementById('container');
72+
const link = document.createElement('a');
73+
link.href = 'https://dynamic-link.com';
74+
link.textContent = 'Dynamic Link';
75+
container?.appendChild(link);
76+
});
77+
78+
await page.waitForTimeout(2500);
79+
80+
const dynamicLink = await page.$('a[href*="dynamic-link.com"]');
81+
const dynamicHref = await dynamicLink?.getAttribute('href');
82+
expect(dynamicHref).toContain('dub_id=test-click-id');
83+
});
84+
85+
test('should handle SPA navigation', async ({ page }) => {
86+
await page.goto('/outbound?dub_id=test-click-id');
87+
88+
await page.waitForFunction(() => window._dubAnalytics !== undefined);
89+
90+
// Simulate SPA navigation
91+
await page.evaluate(() => {
92+
history.pushState({}, '', '/new-page');
93+
const container = document.getElementById('container');
94+
const link = document.createElement('a');
95+
link.href = 'https://example.com';
96+
link.textContent = 'SPA Link';
97+
container?.appendChild(link);
98+
});
99+
100+
await page.waitForTimeout(2500);
101+
102+
const spaLink = await page.$('a[href*="example.com"]');
103+
const spaHref = await spaLink?.getAttribute('href');
104+
expect(spaHref).toContain('dub_id=test-click-id');
105+
});
106+
107+
test('should handle www. prefix and subdomains correctly', async ({
108+
page,
109+
}) => {
110+
await page.goto('/outbound?dub_id=test-click-id');
111+
await page.waitForFunction(() => window._dubAnalytics !== undefined);
112+
113+
await page.waitForTimeout(2500);
114+
115+
// Check www. prefix handling
116+
const wwwLink = await page.$('a[href*="www.example.com"]');
117+
const noWwwLink = await page.$(
118+
'a[href*="example.com"]:not([href*="www."])',
119+
);
120+
const wwwHref = await wwwLink?.getAttribute('href');
121+
const noWwwHref = await noWwwLink?.getAttribute('href');
122+
123+
expect(wwwHref).toContain('dub_id=test-click-id');
124+
expect(noWwwHref).toContain('dub_id=test-click-id');
125+
126+
// Check subdomain handling
127+
const subdomainLink = await page.$(
128+
'a[href*="sub.example.com"]:not([href*="www."])',
129+
);
130+
const otherSubdomainLink = await page.$('a[href*="other.example.com"]');
131+
const wwwSubdomainLink = await page.$('a[href*="www.sub.example.com"]');
132+
133+
const subdomainHref = await subdomainLink?.getAttribute('href');
134+
const otherSubdomainHref = await otherSubdomainLink?.getAttribute('href');
135+
const wwwSubdomainHref = await wwwSubdomainLink?.getAttribute('href');
136+
137+
expect(subdomainHref).toContain('dub_id=test-click-id');
138+
expect(otherSubdomainHref).not.toContain('dub_id=test-click-id');
139+
expect(wwwSubdomainHref).toContain('dub_id=test-click-id');
140+
});
141+
142+
test('should handle www. prefix in iframe src', async ({ page }) => {
143+
await page.goto('/outbound?dub_id=test-click-id');
144+
await page.waitForFunction(() => window._dubAnalytics !== undefined);
145+
146+
await page.waitForTimeout(2500);
147+
148+
const iframe = await page.$('iframe');
149+
const iframeSrc = await iframe?.getAttribute('src');
150+
expect(iframeSrc).toContain('dub_id=test-click-id');
151+
});
152+
});

packages/script/src/base.js

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,8 @@
146146
let clientClickTracked = false;
147147

148148
// Track click and set cookie
149-
function trackClick({ domain, key }) {
150-
if (clientClickTracked) {
151-
return;
152-
}
153-
149+
function trackClick({ domain, key }, serverClickId) {
150+
if (clientClickTracked) return;
154151
clientClickTracked = true;
155152

156153
fetch(`${API_HOST}/track/click`, {
@@ -166,6 +163,12 @@
166163
.then((res) => res.ok && res.json())
167164
.then((data) => {
168165
if (data) {
166+
if (serverClickId && serverClickId !== data.clickId) {
167+
console.warn(
168+
`Client-tracked click ID ${data.clickId} does not match server-tracked click ID ${serverClickId}, skipping...`,
169+
);
170+
return;
171+
}
169172
cookieManager.set(DUB_ID_VAR, data.clickId);
170173
// if partner data is present, set it as dub_partner_data cookie
171174
if (data.partner) {
@@ -212,10 +215,13 @@
212215

213216
// Dub Partners tracking (via query param e.g. ?via=partner_id)
214217
if (QUERY_PARAM_VALUE && SHORT_DOMAIN && shouldSetCookie()) {
215-
trackClick({
216-
domain: SHORT_DOMAIN,
217-
key: QUERY_PARAM_VALUE,
218-
});
218+
trackClick(
219+
{
220+
domain: SHORT_DOMAIN,
221+
key: QUERY_PARAM_VALUE,
222+
},
223+
clickId,
224+
);
219225
}
220226

221227
// Process the queued methods

packages/script/src/extensions/outbound-domains.js

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,75 @@ const initOutboundDomains = () => {
88
} = window._dubAnalytics;
99
let outboundLinksUpdated = new Set(); // Track processed links
1010

11+
function normalizeDomain(domain) {
12+
return domain.replace(/^www\./, '').trim();
13+
}
14+
15+
function isMatchingDomain(url, domain) {
16+
try {
17+
const urlHostname = new URL(url).hostname;
18+
const normalizedUrlHostname = normalizeDomain(urlHostname);
19+
const normalizedDomain = normalizeDomain(domain);
20+
21+
// Exact match after removing www.
22+
return normalizedUrlHostname === normalizedDomain;
23+
} catch (e) {
24+
return false;
25+
}
26+
}
27+
1128
function addOutboundTracking(clickId) {
12-
// Parse comma-separated outbound domains
13-
const outboundDomains = DOMAINS_CONFIG.outbound
14-
?.split(',')
15-
.map((d) => d.trim());
29+
// Handle both string and array configurations for outbound domains
30+
const outboundDomains = Array.isArray(DOMAINS_CONFIG.outbound)
31+
? DOMAINS_CONFIG.outbound
32+
: DOMAINS_CONFIG.outbound?.split(',').map((d) => d.trim());
33+
1634
if (!outboundDomains?.length) return;
1735

18-
const currentDomain = HOSTNAME.replace(/^www\./, '');
19-
const filteredDomains = outboundDomains.filter((d) => d !== currentDomain);
36+
const currentDomain = normalizeDomain(HOSTNAME);
37+
const filteredDomains = outboundDomains
38+
.map(normalizeDomain)
39+
.filter((d) => d !== currentDomain);
2040

2141
const existingCookie = clickId || cookieManager.get(DUB_ID_VAR);
2242
if (!existingCookie) return;
2343

24-
const selector = filteredDomains
25-
.map((domain) => `a[href*="${domain}"]`)
26-
.join(',');
44+
// Get all links and iframes
45+
const elements = document.querySelectorAll('a[href], iframe[src]');
46+
if (!elements || elements.length === 0) return;
2747

28-
const links = document.querySelectorAll(selector);
29-
if (!links || links.length === 0) return;
30-
31-
links.forEach((link) => {
32-
// Skip already processed links
33-
if (outboundLinksUpdated.has(link)) return;
48+
elements.forEach((element) => {
49+
// Skip already processed elements
50+
if (outboundLinksUpdated.has(element)) return;
3451

3552
try {
36-
const url = new URL(link.href);
37-
url.searchParams.set(DUB_ID_VAR, existingCookie);
38-
link.href = url.toString();
39-
outboundLinksUpdated.add(link);
40-
} catch (e) {}
53+
const urlString = element.href || element.src;
54+
if (!urlString) return;
55+
56+
// Check if the URL matches any of our outbound domains
57+
const isOutbound = filteredDomains.some((domain) =>
58+
isMatchingDomain(urlString, domain),
59+
);
60+
if (!isOutbound) return;
61+
62+
const url = new URL(urlString);
63+
64+
// Only add the tracking parameter if it's not already present
65+
if (!url.searchParams.has(DUB_ID_VAR)) {
66+
url.searchParams.set(DUB_ID_VAR, existingCookie);
67+
68+
// Update the appropriate attribute based on element type
69+
if (element.tagName.toLowerCase() === 'a') {
70+
element.href = url.toString();
71+
} else if (element.tagName.toLowerCase() === 'iframe') {
72+
element.src = url.toString();
73+
}
74+
75+
outboundLinksUpdated.add(element);
76+
}
77+
} catch (e) {
78+
console.error('Error processing element:', e);
79+
}
4180
});
4281
}
4382

0 commit comments

Comments
 (0)