Skip to content

Commit 93f7ab0

Browse files
committed
feat(analytics): add public resume view tracking
1 parent da227e0 commit 93f7ab0

3 files changed

Lines changed: 93 additions & 2 deletions

File tree

src/app/[locale]/r/[id]/page.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { MinimalTemplate } from "@/components/builder/templates/minimal-template
99
import { ModernTemplate } from "@/components/builder/templates/modern-template";
1010
import { getPdfLabels, getDateLocale } from "@/lib/pdf-labels";
1111
import { PublicActionBar } from "@/components/builder/public-action-bar";
12+
import { ResumeAnalyticsTracker } from "@/components/builder/resume-analytics-tracker";
1213

1314
interface PublicResumePageProps {
1415
params: Promise<{
@@ -26,7 +27,9 @@ const TEMPLATE_MAP = {
2627
modern: ModernTemplate,
2728
} as const;
2829

29-
export default async function PublicResumePage({ params }: PublicResumePageProps) {
30+
export default async function PublicResumePage({
31+
params,
32+
}: PublicResumePageProps) {
3033
const { locale, id } = await params;
3134
const resume = await getPublicResume(id);
3235

@@ -35,10 +38,12 @@ export default async function PublicResumePage({ params }: PublicResumePageProps
3538
}
3639

3740
const Template =
38-
TEMPLATE_MAP[resume.templateId as keyof typeof TEMPLATE_MAP] ?? HarvardTemplate;
41+
TEMPLATE_MAP[resume.templateId as keyof typeof TEMPLATE_MAP] ??
42+
HarvardTemplate;
3943

4044
return (
4145
<div className="min-h-screen bg-gray-100 print:bg-white pt-14 print:pt-0">
46+
<ResumeAnalyticsTracker resumeId={resume.id} />
4247
<PublicActionBar resumeTitle={resume.title} />
4348
<div className="py-8 px-4 print:py-0 print:px-0">
4449
<div className="mx-auto max-w-[900px] bg-white shadow-2xl print:shadow-none">
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { NextResponse } from "next/server";
2+
import { db } from "@/db";
3+
import { resumeViews } from "@/db/schema";
4+
5+
export async function POST(request: Request) {
6+
try {
7+
const rawBody = await request.text();
8+
if (!rawBody) return NextResponse.json({ success: true });
9+
10+
let body;
11+
try {
12+
body = JSON.parse(rawBody);
13+
} catch {
14+
return NextResponse.json({ success: true }); // fail silently for analytics
15+
}
16+
17+
const { resumeId, location, durationSeconds } = body;
18+
19+
if (!resumeId) {
20+
return NextResponse.json({ success: true });
21+
}
22+
23+
// Attempt to parse out some pseudo-IP or user agent to prevent infinite logging
24+
// by the same exact user in a loop, but keep it lightweight.
25+
const ip = request.headers.get("x-forwarded-for") || "unknown";
26+
const userAgent = request.headers.get("user-agent") || "unknown";
27+
28+
// Hash IP safely so we don't store plain PII
29+
// For a real production app, do a proper hash/salt, but for a showcase, string concat is okay
30+
const viewerIpId = `hash_${ip.substring(0, 10)}_${userAgent.substring(0, 15)}`;
31+
32+
await db.insert(resumeViews).values({
33+
resumeId,
34+
location: location || "Unknown Region",
35+
durationSeconds: durationSeconds || 0,
36+
viewerIpId,
37+
});
38+
39+
return NextResponse.json({ success: true });
40+
} catch (error) {
41+
console.error("Failed to record analytics:", error);
42+
return NextResponse.json({ success: false }, { status: 500 });
43+
}
44+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
5+
export function ResumeAnalyticsTracker({ resumeId }: { resumeId: string }) {
6+
const isRecorded = useRef(false);
7+
8+
useEffect(() => {
9+
if (isRecorded.current) return;
10+
11+
// Track page view load
12+
const startTime = Date.now();
13+
isRecorded.current = true;
14+
15+
// Try to get geolocation approximations
16+
let location = "Unknown";
17+
try {
18+
location = Intl.DateTimeFormat().resolvedOptions().timeZone;
19+
} catch {
20+
// Ignore error and fall back to "Unknown"
21+
}
22+
23+
// When the component unmounts (user leaves), we record the analytics
24+
return () => {
25+
const durationSeconds = Math.round((Date.now() - startTime) / 1000);
26+
27+
// We use a small blob trick to send the data on unload using sendBeacon or fetch keepalive
28+
const data = {
29+
resumeId,
30+
location,
31+
durationSeconds,
32+
};
33+
34+
const blob = new Blob([JSON.stringify(data)], {
35+
type: "application/json",
36+
});
37+
navigator.sendBeacon("/api/analytics/resume-view", blob);
38+
};
39+
}, [resumeId]);
40+
41+
return null;
42+
}

0 commit comments

Comments
 (0)