Skip to content

Commit 73b5be5

Browse files
authored
feat(site): utm persistence (#7780)
* feat(site): utm persistence * fix(site): failing build * fix(site): harden persisted utm propagation Preserve arbitrary persisted UTM fields across site and console navigation while making storage access safer and available on first render. Made-with: Cursor * fix(site): apply persisted utms to console links Extend the site UTM persistence handler to rewrite console.prisma.io links and simplify nav UTM resolution so page-level CTAs inherit the stored campaign parameters. Made-with: Cursor * fix(site): preserve exact utm payloads Use the incoming UTM set exactly when one is present, fall back to defaults only when no UTM exists, and bring the same persistence behavior to the blog shell. Made-with: Cursor * fix(site): hydrate homepage console ctas safely Avoid nav hydration mismatches by deferring exact UTM resolution until after mount and apply the same exact-or-default UTM handling to the homepage create database CTAs. Made-with: Cursor * fix(site): narrow console cta button link props Use the anchor variant of the shared Button props so homepage console CTAs accept link-only attributes like target and rel during type checking. Made-with: Cursor * refactor(site): reuse homepage default utm config Extract the shared homepage CTA UTM payload into a single constant so the create database buttons stay aligned when campaign defaults change. Made-with: Cursor
1 parent 051e12b commit 73b5be5

11 files changed

Lines changed: 582 additions & 20 deletions

File tree

apps/blog/src/app/(blog)/layout.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { WebNavigation } from "@prisma-docs/ui/components/web-navigation";
21
import { Footer } from "@prisma-docs/ui/components/footer";
32
import { ThemeProvider } from "@prisma-docs/ui/components/theme-provider";
3+
import { NavigationWrapper } from "@/components/navigation-wrapper";
4+
import { UtmPersistence } from "@/components/utm-persistence";
45
export function baseOptions() {
56
return {
67
nav: {
@@ -103,7 +104,8 @@ export function baseOptions() {
103104
export default function Layout({ children }: { children: React.ReactNode }) {
104105
return (
105106
<ThemeProvider defaultTheme="system" storageKey="theme">
106-
<WebNavigation
107+
<UtmPersistence />
108+
<NavigationWrapper
107109
links={baseOptions().links}
108110
utm={{ source: "website", medium: "blog" }}
109111
/>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"use client";
2+
3+
import { WebNavigation } from "@prisma-docs/ui/components/web-navigation";
4+
import { useEffect, useState } from "react";
5+
import { getUtmParams, hasUtmParams, type UtmParams } from "@/lib/utm";
6+
7+
interface Link {
8+
text: string;
9+
external?: boolean;
10+
url?: string;
11+
icon?: string;
12+
desc?: string;
13+
col?: number;
14+
sub?: Array<{
15+
text: string;
16+
external?: boolean;
17+
url: string;
18+
icon?: string;
19+
desc?: string;
20+
}>;
21+
}
22+
23+
interface NavigationWrapperProps {
24+
links: Link[];
25+
utm: {
26+
source: string;
27+
medium: string;
28+
};
29+
}
30+
31+
export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
32+
const [mounted, setMounted] = useState(false);
33+
const defaultUtmParams = {
34+
utm_source: utm.source,
35+
utm_medium: utm.medium,
36+
};
37+
38+
useEffect(() => {
39+
setMounted(true);
40+
}, []);
41+
42+
const currentUtmParams: UtmParams =
43+
mounted ? getUtmParams(new URLSearchParams(window.location.search)) : {};
44+
const preserveExactUtm = hasUtmParams(currentUtmParams);
45+
const resolvedUtmParams = preserveExactUtm ? currentUtmParams : defaultUtmParams;
46+
47+
return (
48+
<WebNavigation
49+
links={links}
50+
utm={resolvedUtmParams}
51+
preserveExactUtm={preserveExactUtm}
52+
/>
53+
);
54+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { usePathname, useRouter } from "next/navigation";
5+
import {
6+
clearStoredUtmParams,
7+
CONSOLE_HOST,
8+
getUtmParams,
9+
hasUtmParams,
10+
syncUtmParams,
11+
writeStoredUtmParams,
12+
} from "@/lib/utm";
13+
14+
export function UtmPersistence() {
15+
const pathname = usePathname();
16+
const router = useRouter();
17+
18+
useEffect(() => {
19+
const currentUtmParams = getUtmParams(
20+
new URLSearchParams(window.location.search),
21+
);
22+
23+
if (hasUtmParams(currentUtmParams)) {
24+
writeStoredUtmParams(currentUtmParams);
25+
return;
26+
}
27+
28+
clearStoredUtmParams();
29+
}, [pathname]);
30+
31+
useEffect(() => {
32+
function handleClick(event: MouseEvent) {
33+
if (event.defaultPrevented || event.button !== 0) {
34+
return;
35+
}
36+
37+
const anchor = (event.target as HTMLElement).closest<HTMLAnchorElement>(
38+
"a[href]",
39+
);
40+
41+
if (!anchor) {
42+
return;
43+
}
44+
45+
const href = anchor.getAttribute("href");
46+
47+
if (
48+
!href ||
49+
href.startsWith("#") ||
50+
href.startsWith("mailto:") ||
51+
href.startsWith("tel:") ||
52+
anchor.hasAttribute("download")
53+
) {
54+
return;
55+
}
56+
57+
const activeUtmParams = getUtmParams(
58+
new URLSearchParams(window.location.search),
59+
);
60+
61+
if (!hasUtmParams(activeUtmParams)) {
62+
return;
63+
}
64+
65+
const targetUrl = new URL(anchor.href, window.location.href);
66+
const isInternalLink = targetUrl.origin === window.location.origin;
67+
const isConsoleLink = targetUrl.hostname === CONSOLE_HOST;
68+
69+
if (!isInternalLink && !isConsoleLink) {
70+
return;
71+
}
72+
73+
if (!syncUtmParams(targetUrl, activeUtmParams)) {
74+
return;
75+
}
76+
77+
const nextHref = `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`;
78+
const isModifiedClick =
79+
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
80+
81+
if (isInternalLink && anchor.target !== "_blank" && !isModifiedClick) {
82+
event.preventDefault();
83+
router.push(nextHref);
84+
return;
85+
}
86+
87+
anchor.setAttribute(
88+
"href",
89+
isInternalLink ? nextHref : targetUrl.toString(),
90+
);
91+
}
92+
93+
document.addEventListener("click", handleClick, true);
94+
return () => document.removeEventListener("click", handleClick, true);
95+
}, [router]);
96+
97+
return null;
98+
}

apps/blog/src/lib/utm.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
export const UTM_STORAGE_KEY = "blog_utm_params";
2+
export const CONSOLE_HOST = "console.prisma.io";
3+
4+
export type UtmParams = Record<string, string>;
5+
6+
function sanitizeUtmParams(input: unknown): UtmParams {
7+
if (!input || typeof input !== "object" || Array.isArray(input)) {
8+
return {};
9+
}
10+
11+
return Object.fromEntries(
12+
Object.entries(input).filter(
13+
([key, value]) =>
14+
key.startsWith("utm_") && typeof value === "string" && value.length > 0,
15+
),
16+
);
17+
}
18+
19+
export function getUtmParams(searchParams: URLSearchParams): UtmParams {
20+
const utmParams: UtmParams = {};
21+
22+
for (const [key, value] of searchParams.entries()) {
23+
if (key.startsWith("utm_") && value) {
24+
utmParams[key] = value;
25+
}
26+
}
27+
28+
return utmParams;
29+
}
30+
31+
export function hasUtmParams(utmParams: UtmParams) {
32+
return Object.keys(utmParams).length > 0;
33+
}
34+
35+
export function syncUtmParams(url: URL, utmParams: UtmParams) {
36+
let updated = false;
37+
38+
for (const key of Array.from(url.searchParams.keys())) {
39+
if (key.startsWith("utm_") && !(key in utmParams)) {
40+
url.searchParams.delete(key);
41+
updated = true;
42+
}
43+
}
44+
45+
for (const [key, value] of Object.entries(utmParams)) {
46+
if (url.searchParams.get(key) !== value) {
47+
url.searchParams.set(key, value);
48+
updated = true;
49+
}
50+
}
51+
52+
return updated;
53+
}
54+
55+
export function readStoredUtmParams() {
56+
if (typeof window === "undefined") {
57+
return {};
58+
}
59+
60+
try {
61+
const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY);
62+
63+
if (!storedUtmParams) {
64+
return {};
65+
}
66+
67+
return sanitizeUtmParams(JSON.parse(storedUtmParams));
68+
} catch {
69+
return {};
70+
}
71+
}
72+
73+
export function writeStoredUtmParams(utmParams: UtmParams) {
74+
if (typeof window === "undefined") {
75+
return;
76+
}
77+
78+
const validUtmParams = sanitizeUtmParams(utmParams);
79+
80+
if (!hasUtmParams(validUtmParams)) {
81+
return;
82+
}
83+
84+
try {
85+
window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(validUtmParams));
86+
} catch {
87+
// Ignore storage failures in restricted environments.
88+
}
89+
}
90+
91+
export function clearStoredUtmParams() {
92+
if (typeof window === "undefined") {
93+
return;
94+
}
95+
96+
try {
97+
window.sessionStorage.removeItem(UTM_STORAGE_KEY);
98+
} catch {
99+
// Ignore storage failures in restricted environments.
100+
}
101+
}

apps/site/src/app/(index)/page.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@ import { Button } from "@prisma/eclipse";
55
import { CopyCode } from "@/components/homepage/copy-btn";
66
import { Bento } from "@/components/homepage/bento";
77
import { CardSection } from "@/components/homepage/card-section/card-section";
8+
import { ConsoleCtaButton } from "@/components/console-cta-button";
89
import review from "../../data/homepage.json";
910
import Testimonials from "../../components/homepage/testimonials";
1011

12+
const INDEX_CTA_DEFAULT_UTM = {
13+
utm_source: "website",
14+
utm_medium: "index",
15+
utm_campaign: "cta",
16+
} as const;
17+
1118
const twoCol = [
1219
{
1320
content: (
@@ -114,17 +121,18 @@ export default function SiteHome() {
114121
ship faster.
115122
</p>
116123
<div className="flex flex-col md:flex-row gap-4 items-center justify-center">
117-
<Button
124+
<ConsoleCtaButton
118125
variant="ppg"
119-
href="https://console.prisma.io/sign-up?utm_source=website&utm_medium=index&utm_campaign=cta"
126+
consolePath="/sign-up"
127+
defaultUtm={INDEX_CTA_DEFAULT_UTM}
120128
size="3xl"
121129
target="_blank"
122130
rel="noopener noreferrer"
123131
className="font-sans-display! font-[650]"
124132
>
125133
<span>Create database</span>
126134
<i className="fa-regular fa-database ml-2" />
127-
</Button>
135+
</ConsoleCtaButton>
128136
<CopyCode text="npx prisma init">
129137
<span className="text-foreground-neutral-reverse-weak">$</span>
130138
<span className="text-foreground-neutral-weak">
@@ -246,14 +254,15 @@ export default function SiteHome() {
246254
</p>
247255
</div>
248256
<div className="flex flex-col gap-6 md:flex-row">
249-
<Button
257+
<ConsoleCtaButton
250258
variant="ppg"
251259
size="2xl"
252-
href="https://console.prisma.io/sign-up?utm_source=website&utm_medium=index&utm_campaign=cta"
260+
consolePath="/sign-up"
261+
defaultUtm={INDEX_CTA_DEFAULT_UTM}
253262
>
254263
<span>Create your first Database</span>
255264
<i className="fa-regular fa-arrow-right ml-2" />
256-
</Button>
265+
</ConsoleCtaButton>
257266
<Button variant="default-stronger" size="2xl" href="/pricing">
258267
<span>Explore Pricing</span>
259268
<i className="fa-regular fa-arrow-right ml-2" />

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"],
@@ -191,6 +192,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
191192
<div className="bg-background-default absolute inset-0 -z-1 overflow-hidden" />
192193
<Provider>
193194
<ThemeProvider defaultTheme="system" storageKey="theme">
195+
<UtmPersistence />
194196
<NavigationWrapper
195197
links={baseOptions().links}
196198
utm={{ source: "website" }}

0 commit comments

Comments
 (0)