Skip to content

Commit 50b0fb1

Browse files
committed
cta click tracker
1 parent 4a9f216 commit 50b0fb1

5 files changed

Lines changed: 109 additions & 2 deletions

File tree

app/api/insight/cta/log/route.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { type NextRequest, NextResponse } from 'next/server';
2+
3+
import { signedFetch } from '@/lib/signed-fetch';
4+
5+
export async function POST(req: NextRequest) {
6+
const { token } = (await req.json()) as { token?: string };
7+
if (!token) {
8+
return NextResponse.json({ message: 'Token is required' }, { status: 400 });
9+
}
10+
11+
const forwardedFor = req.headers.get('x-forwarded-for');
12+
const ip = forwardedFor?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || undefined;
13+
const userAgent = req.headers.get('user-agent') || undefined;
14+
const referer = req.headers.get('referer') || undefined;
15+
16+
const response = await signedFetch('/insight/cta/log', {
17+
method: 'POST',
18+
headers: { 'Content-Type': 'application/json' },
19+
body: JSON.stringify({ token, ip, userAgent, referer }),
20+
cache: 'no-store',
21+
});
22+
23+
if (!response.ok) {
24+
return NextResponse.json({ logged: false }, { status: response.status });
25+
}
26+
27+
const data = await response.json();
28+
return NextResponse.json(data);
29+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
5+
const TOKEN_QUERY_PARAM = 't';
6+
const TRACKED_TOKENS_KEY = 'tracked_cta_tokens_v1';
7+
const MAX_TRACKED_TOKENS = 50;
8+
9+
function getTrackedTokens(): string[] {
10+
try {
11+
const raw = window.sessionStorage.getItem(TRACKED_TOKENS_KEY);
12+
if (!raw) return [];
13+
const parsed = JSON.parse(raw);
14+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
15+
} catch {
16+
return [];
17+
}
18+
}
19+
20+
function setTrackedTokens(tokens: string[]) {
21+
try {
22+
window.sessionStorage.setItem(TRACKED_TOKENS_KEY, JSON.stringify(tokens.slice(-MAX_TRACKED_TOKENS)));
23+
} catch {
24+
// Ignore storage errors in private browsing modes.
25+
}
26+
}
27+
28+
function removeTrackingTokenFromUrl() {
29+
const url = new URL(window.location.href);
30+
if (!url.searchParams.has(TOKEN_QUERY_PARAM)) {
31+
return;
32+
}
33+
url.searchParams.delete(TOKEN_QUERY_PARAM);
34+
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
35+
}
36+
37+
async function sendClickLog(token: string) {
38+
const payload = JSON.stringify({ token });
39+
40+
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
41+
const blob = new Blob([payload], { type: 'application/json' });
42+
const accepted = navigator.sendBeacon('/api/insight/cta/log', blob);
43+
if (accepted) {
44+
return;
45+
}
46+
}
47+
48+
await fetch('/api/insight/cta/log', {
49+
method: 'POST',
50+
headers: { 'Content-Type': 'application/json' },
51+
body: payload,
52+
keepalive: true,
53+
});
54+
}
55+
56+
export function CtaClickTracker() {
57+
useEffect(() => {
58+
const url = new URL(window.location.href);
59+
const token = url.searchParams.get(TOKEN_QUERY_PARAM);
60+
if (!token) {
61+
return;
62+
}
63+
64+
const trackedTokens = getTrackedTokens();
65+
if (trackedTokens.includes(token)) {
66+
removeTrackingTokenFromUrl();
67+
return;
68+
}
69+
70+
void sendClickLog(token);
71+
setTrackedTokens([...trackedTokens, token]);
72+
removeTrackingTokenFromUrl();
73+
}, []);
74+
75+
return null;
76+
}

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SessionProvider } from 'next-auth/react';
55
import { NuqsAdapter } from 'nuqs/adapters/next/app';
66
import { Suspense } from 'react';
77

8+
import { CtaClickTracker } from './components/cta-click-tracker';
89
import { FlagEmojiPolyfill } from './components/flag-emoji-polyfill';
910
import { Announcement } from '@/components/announcement/announcement';
1011
import { Footer } from '@/components/footer/footer';
@@ -35,6 +36,7 @@ export default function RootLayout({ children }: LayoutProps<'/'>) {
3536
<NuqsAdapter>
3637
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
3738
<FlagEmojiPolyfill />
39+
<CtaClickTracker />
3840
<div className="flex flex-col min-h-screen">
3941
<div className="grow">
4042
<Suspense fallback={null}>{children}</Suspense>

codegen.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { CodegenConfig } from '@graphql-codegen/cli';
22
import jwt from 'jsonwebtoken';
33

4-
const token = jwt.sign({ sub: 'codegen', typ: 'access' }, process.env.INTERNAL_JWT_SECRET!, { expiresIn: '1h' });
4+
const token = jwt.sign({ sub: 'codegen', typ: 'access' }, process.env.INTERNAL_JWT_SECRET!, { expiresIn: '30m' });
55

66
const config: CodegenConfig = {
77
schema: {

lib/signed-fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import jwt from 'jsonwebtoken';
44

55
export async function signedFetch(path: string, init: RequestInit = {}) {
6-
const token = jwt.sign({}, process.env.INTERNAL_JWT_SECRET!, { expiresIn: '2m' });
6+
const token = jwt.sign({}, process.env.INTERNAL_JWT_SECRET!, { expiresIn: '5m' });
77

88
const headers = new Headers(init.headers);
99
headers.set('Authorization', `Bearer ${token}`);

0 commit comments

Comments
 (0)