Skip to content

Commit 4cc9790

Browse files
Capture marketing source on all landing routes
captureGclid only ran inside PublicLayout's effect, which mounts on /figures, /themes, /about, /contact. Shortcut redirects in _redirects target / and /de/, neither of which ever called it. Paid clicks were silently bucketed as 'direct'. - captureGclid() moves to index.tsx module load so it runs before the router can strip URL params with any catch-all redirect. - The catch-all redirect now preserves location.search instead of dropping it, so unmatched paths still carry the tag forward. - Allowlist (client + both workers + dashboard color map) gains two campaign-variant labels so A/B comparisons render as separate rows.
1 parent 4aeeb3a commit 4cc9790

7 files changed

Lines changed: 36 additions & 13 deletions

File tree

client/src/App.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
createBrowserRouter,
44
RouterProvider,
55
Navigate,
6-
Outlet
6+
Outlet,
7+
useLocation
78
} from 'react-router-dom';
89
import HomePage from './pages/HomePage';
910
import LoginPage from './pages/LoginPage';
@@ -66,6 +67,14 @@ declare global {
6667
}
6768
}
6869

70+
// Catch-all redirect that preserves the current URL's search string. Default
71+
// <Navigate to="/" /> drops query params, which would lose utm tags from
72+
// shortcut redirects (e.g. /sp/de1 → /de/?utm_source=spotify_a → catch-all).
73+
function NavigateKeepingSearch({ to }: { to: string }): React.ReactElement {
74+
const { search } = useLocation();
75+
return <Navigate to={{ pathname: to, search }} replace />;
76+
}
77+
6978
function App(): React.ReactElement {
7079
// Initialize from localStorage hint (synchronous) to avoid login flash on refresh
7180
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(
@@ -422,7 +431,7 @@ function App(): React.ReactElement {
422431
},
423432
{
424433
path: "*",
425-
element: <Navigate to="/" replace />
434+
element: <NavigateKeepingSearch to="/" />
426435
}
427436
]
428437
}

client/src/components/public/PublicLayout.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,15 @@
22
// Provides: language context, navbar, footer, breadcrumbs, scroll override
33
// Remove this file when stripping marketing pages from a fork
44

5-
import { useEffect } from 'react';
65
import { Outlet } from 'react-router-dom';
76
import { PublicLangProvider } from './PublicLangContext';
87
import PublicNavbar from './PublicNavbar';
98
import PublicFooter from './PublicFooter';
10-
import { captureGclid } from '../../utils/public/gclidCapture';
119
import '../../styles/public/public-layout.css';
1210
import '../../styles/public/public-pages.css';
1311
import '../../styles/public/public-cards.css';
1412

1513
export default function PublicLayout() {
16-
// Capture gclid from URL on first render (Google Ads click tracking)
17-
useEffect(() => { captureGclid(); }, []);
18-
1914
return (
2015
<PublicLangProvider>
2116
<div className="public-page">

client/src/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ import BYOKSetupModal from './components/BYOKSetupModal';
1111
import { useDomainStore } from './stores/domainStore';
1212
import { initializeSeedsCache } from './services/seedCacheInitializer';
1313
import { LocalStorageAdapter } from './storage/localAdapter';
14+
import { captureGclid } from './utils/public/gclidCapture';
15+
16+
// Capture utm_source / gclid from the landing URL before any router redirect
17+
// can strip them. Must run synchronously at module load — running inside a
18+
// React effect is too late, because the catch-all <Navigate> in App.tsx
19+
// rewrites the URL before App's effect fires.
20+
captureGclid();
1421
// Service Worker registration (DISABLED until Q1 2026 - Offline Mode implementation)
1522
// Currently causes 404 errors since service-worker.js doesn't exist yet
1623
// Roadmap: CLAUDE.md Q1 2026 - Offline Mode with service worker

client/src/utils/public/gclidCapture.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ let capturedMarketingSource: MarketingSource = 'direct';
1010

1111
// Closed allowlist for marketing source labels written to analytics blobs.
1212
// Server side validates against the same allowlist; unknown values become 'unknown'.
13-
export type MarketingSource = 'spotify' | 'grants' | 'paid' | 'organic' | 'direct' | 'unknown';
13+
// spotify_a / spotify_b are A/B variants of the Spotify campaign — short URLs in
14+
// `client/public/_redirects` (e.g. /sp/de1, /sp/de2) split the channel so the
15+
// dashboard renders them as separate rows for variant comparison.
16+
export type MarketingSource =
17+
| 'spotify' | 'spotify_a' | 'spotify_b'
18+
| 'grants' | 'paid' | 'organic' | 'direct' | 'unknown';
1419

1520
const ALLOWED_SOURCES: ReadonlySet<MarketingSource> = new Set([
16-
'spotify', 'grants', 'paid', 'organic', 'direct', 'unknown',
21+
'spotify', 'spotify_a', 'spotify_b',
22+
'grants', 'paid', 'organic', 'direct', 'unknown',
1723
]);
1824

1925
/**
@@ -26,6 +32,8 @@ function normalizeMarketingSource(utmSource: string | null, utmMedium: string |
2632
const medium = (utmMedium || '').toLowerCase();
2733

2834
if (source === 'spotify') return 'spotify';
35+
if (source === 'spotify_a') return 'spotify_a';
36+
if (source === 'spotify_b') return 'spotify_b';
2937

3038
if (source === 'google' || source === 'googleads') {
3139
if (medium === 'grants' || medium === 'adgrants') return 'grants';

workers/audio-proxy/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import type { Env } from './types';
88
// Closed allowlist for marketing source labels — mirrors llm-proxy/utils/analytics.ts.
99
// Validated server-side so the dashboard never sees free-text values.
1010
const ALLOWED_MARKETING_SOURCES = new Set([
11-
'spotify', 'grants', 'paid', 'organic', 'direct', 'unknown',
11+
'spotify', 'spotify_a', 'spotify_b',
12+
'grants', 'paid', 'organic', 'direct', 'unknown',
1213
]);
1314

1415
function readMarketingSource(request: Request): string {

workers/llm-proxy/src/utils/analytics.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import type { Env } from './types';
77

88
// Closed allowlist for marketing source labels. Validated server-side to
99
// guarantee the dashboard never sees free-text values, even if the client
10-
// header is malformed or hostile.
10+
// header is malformed or hostile. spotify_a / spotify_b are A/B variants
11+
// of the Spotify campaign — see client/public/_redirects for the shortcut
12+
// rules and gclidCapture.ts for the matching client-side allowlist.
1113
const ALLOWED_MARKETING_SOURCES = new Set([
12-
'spotify', 'grants', 'paid', 'organic', 'direct', 'unknown',
14+
'spotify', 'spotify_a', 'spotify_b',
15+
'grants', 'paid', 'organic', 'direct', 'unknown',
1316
]);
1417

1518
/**

workers/stats/src/dashboard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1915,7 +1915,7 @@ async function loadAdGrants() {
19151915
allBuckets.add(row.t);
19161916
});
19171917
var bucketArr = Array.from(allBuckets).sort();
1918-
var sourceColors = { spotify: '#1DB954', grants: '#4285F4', paid: '#E97451', organic: '#9D83CD', direct: '#E6BC5C', unknown: '#8A8A8A' };
1918+
var sourceColors = { spotify: '#1DB954', spotify_a: '#3DD27A', spotify_b: '#0E8A3F', grants: '#4285F4', paid: '#E97451', organic: '#9D83CD', direct: '#E6BC5C', unknown: '#8A8A8A' };
19191919
var maxBucket = 1;
19201920
bucketArr.forEach(function(t) {
19211921
var stack = 0;

0 commit comments

Comments
 (0)