Skip to content

Commit 560fe89

Browse files
committed
feat(site): utm persistence
1 parent 6ddbbee commit 560fe89

5 files changed

Lines changed: 219 additions & 10 deletions

File tree

apps/site/src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { Footer } from "@prisma-docs/ui/components/footer";
1616
import { ThemeProvider } from "@prisma-docs/ui/components/theme-provider";
1717
import { FontAwesomeScript as WebFA } from "@prisma/eclipse";
18+
import { UtmPersistence } from "@/components/utm-persistence";
1819

1920
const inter = Inter({
2021
subsets: ["latin"],
@@ -187,6 +188,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
187188
<div className="bg-background-default absolute inset-0 -z-1 overflow-hidden" />
188189
<Provider>
189190
<ThemeProvider defaultTheme="system" storageKey="theme">
191+
<UtmPersistence />
190192
<NavigationWrapper
191193
links={baseOptions().links}
192194
utm={{ source: "website" }}

apps/site/src/components/navigation-wrapper.tsx

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22

33
import { WebNavigation } from "@prisma-docs/ui/components/web-navigation";
44
import { Footer } from "@prisma-docs/ui/components/footer";
5-
import { usePathname } from "next/navigation";
5+
import { useEffect, useState } from "react";
6+
import { usePathname, useSearchParams } from "next/navigation";
7+
import {
8+
getUtmParams,
9+
readStoredUtmParams,
10+
type UtmParams,
11+
writeStoredUtmParams,
12+
} from "@/lib/utm";
613

714
interface Link {
815
text: string;
@@ -23,7 +30,7 @@ interface Link {
2330
interface NavigationWrapperProps {
2431
links: Link[];
2532
utm: {
26-
source: "website";
33+
source: string;
2734
};
2835
}
2936

@@ -49,6 +56,27 @@ function getUtmMedium(pathname: string) {
4956

5057
export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
5158
const pathname = usePathname();
59+
const searchParams = useSearchParams();
60+
const [storedUtmParams, setStoredUtmParams] = useState<UtmParams>({
61+
utm_source: utm.source,
62+
});
63+
64+
useEffect(() => {
65+
const currentUtmParams = getUtmParams(new URLSearchParams(searchParams.toString()));
66+
67+
if (currentUtmParams.utm_source) {
68+
setStoredUtmParams(currentUtmParams);
69+
writeStoredUtmParams(currentUtmParams);
70+
return;
71+
}
72+
73+
const persistedUtmParams = readStoredUtmParams();
74+
setStoredUtmParams(
75+
persistedUtmParams.utm_source
76+
? persistedUtmParams
77+
: { utm_source: utm.source },
78+
);
79+
}, [searchParams, utm.source]);
5280

5381
// Determine button variant based on pathname
5482
const getButtonVariant = (): ColorType => {
@@ -62,7 +90,13 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
6290
return (
6391
<WebNavigation
6492
links={links}
65-
utm={{ source: utm.source, medium: getUtmMedium(pathname) }}
93+
utm={{
94+
source: storedUtmParams.utm_source || utm.source,
95+
medium: storedUtmParams.utm_medium || getUtmMedium(pathname),
96+
campaign: storedUtmParams.utm_campaign,
97+
content: storedUtmParams.utm_content,
98+
term: storedUtmParams.utm_term,
99+
}}
66100
buttonVariant={getButtonVariant()}
67101
/>
68102
);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { usePathname, useSearchParams } from "next/navigation";
5+
import {
6+
getUtmParams,
7+
hasUtmParams,
8+
mergeUtmParams,
9+
readStoredUtmParams,
10+
writeStoredUtmParams,
11+
} from "@/lib/utm";
12+
13+
export function UtmPersistence() {
14+
const pathname = usePathname();
15+
const searchParams = useSearchParams();
16+
17+
useEffect(() => {
18+
const currentUtmParams = getUtmParams(new URLSearchParams(searchParams.toString()));
19+
20+
if (hasUtmParams(currentUtmParams)) {
21+
writeStoredUtmParams(currentUtmParams);
22+
return;
23+
}
24+
25+
const storedUtmParams = readStoredUtmParams();
26+
27+
if (!hasUtmParams(storedUtmParams)) {
28+
return;
29+
}
30+
31+
const currentUrl = new URL(window.location.href);
32+
33+
if (!mergeUtmParams(currentUrl, storedUtmParams)) {
34+
return;
35+
}
36+
37+
window.history.replaceState(
38+
window.history.state,
39+
"",
40+
`${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`,
41+
);
42+
}, [pathname, searchParams]);
43+
44+
useEffect(() => {
45+
function handleClick(event: MouseEvent) {
46+
const anchor = (event.target as HTMLElement).closest<HTMLAnchorElement>(
47+
"a[href]",
48+
);
49+
50+
if (!anchor) {
51+
return;
52+
}
53+
54+
const href = anchor.getAttribute("href");
55+
56+
if (
57+
!href ||
58+
href.startsWith("#") ||
59+
href.startsWith("mailto:") ||
60+
href.startsWith("tel:") ||
61+
anchor.target === "_blank" ||
62+
anchor.hasAttribute("download")
63+
) {
64+
return;
65+
}
66+
67+
const storedUtmParams = readStoredUtmParams();
68+
69+
if (!hasUtmParams(storedUtmParams)) {
70+
return;
71+
}
72+
73+
const targetUrl = new URL(anchor.href, window.location.href);
74+
75+
if (targetUrl.origin !== window.location.origin) {
76+
return;
77+
}
78+
79+
if (!mergeUtmParams(targetUrl, storedUtmParams)) {
80+
return;
81+
}
82+
83+
anchor.setAttribute(
84+
"href",
85+
`${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`,
86+
);
87+
}
88+
89+
document.addEventListener("click", handleClick, true);
90+
return () => document.removeEventListener("click", handleClick, true);
91+
}, []);
92+
93+
return null;
94+
}

apps/site/src/lib/utm.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
export const UTM_STORAGE_KEY = "site_utm_params";
2+
3+
export type UtmParams = Record<string, string>;
4+
5+
export function getUtmParams(searchParams: URLSearchParams): UtmParams {
6+
const utmParams: UtmParams = {};
7+
8+
for (const [key, value] of searchParams.entries()) {
9+
if (key.startsWith("utm_") && value) {
10+
utmParams[key] = value;
11+
}
12+
}
13+
14+
return utmParams;
15+
}
16+
17+
export function hasUtmParams(utmParams: UtmParams) {
18+
return Object.keys(utmParams).length > 0;
19+
}
20+
21+
export function mergeUtmParams(url: URL, utmParams: UtmParams) {
22+
let updated = false;
23+
24+
for (const [key, value] of Object.entries(utmParams)) {
25+
if (!url.searchParams.has(key)) {
26+
url.searchParams.set(key, value);
27+
updated = true;
28+
}
29+
}
30+
31+
return updated;
32+
}
33+
34+
export function readStoredUtmParams() {
35+
const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY);
36+
37+
if (!storedUtmParams) {
38+
return {};
39+
}
40+
41+
try {
42+
return JSON.parse(storedUtmParams) as UtmParams;
43+
} catch {
44+
return {};
45+
}
46+
}
47+
48+
export function writeStoredUtmParams(utmParams: UtmParams) {
49+
if (hasUtmParams(utmParams)) {
50+
window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(utmParams));
51+
}
52+
}

packages/ui/src/components/web-navigation.tsx

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,51 @@ export interface Link {
3838
interface WebNavigationProps {
3939
links: Link[];
4040
utm?: {
41-
source: "website";
41+
source: string;
4242
medium: string;
43+
campaign?: string;
44+
content?: string;
45+
term?: string;
4346
};
4447
buttonVariant?: "ppg" | "orm" | undefined;
4548
}
4649

50+
function buildConsoleHref(
51+
pathname: "/login" | "/sign-up",
52+
utm?: WebNavigationProps["utm"],
53+
) {
54+
if (!utm) {
55+
return `https://console.prisma.io${pathname}`;
56+
}
57+
58+
const href = new URL(`https://console.prisma.io${pathname}`);
59+
60+
href.searchParams.set("utm_source", utm.source);
61+
href.searchParams.set("utm_medium", utm.medium);
62+
href.searchParams.set(
63+
"utm_campaign",
64+
utm.campaign || (pathname === "/login" ? "login" : "signup"),
65+
);
66+
67+
if (utm.content) {
68+
href.searchParams.set("utm_content", utm.content);
69+
}
70+
71+
if (utm.term) {
72+
href.searchParams.set("utm_term", utm.term);
73+
}
74+
75+
return href.toString();
76+
}
77+
4778
export function WebNavigation({
4879
links,
4980
utm,
5081
buttonVariant = "ppg",
5182
}: WebNavigationProps) {
5283
const [mobileView, setMobileView] = useState(false);
53-
const loginHref = utm
54-
? `https://console.prisma.io/login?utm_source=${utm.source}&utm_medium=${utm.medium}&utm_campaign=login`
55-
: "https://console.prisma.io/login";
56-
const signupHref = utm
57-
? `https://console.prisma.io/sign-up?utm_source=${utm.source}&utm_medium=${utm.medium}&utm_campaign=signup`
58-
: "https://console.prisma.io/sign-up";
84+
const loginHref = buildConsoleHref("/login", utm);
85+
const signupHref = buildConsoleHref("/sign-up", utm);
5986

6087
useEffect(() => {
6188
if (mobileView) {

0 commit comments

Comments
 (0)