From 560fe89140111431ea464125ea3774ec1bf924ba Mon Sep 17 00:00:00 2001 From: Mike Hartington Date: Tue, 7 Apr 2026 09:46:12 -0400 Subject: [PATCH 1/8] feat(site): utm persistence --- apps/site/src/app/layout.tsx | 2 + .../src/components/navigation-wrapper.tsx | 40 +++++++- apps/site/src/components/utm-persistence.tsx | 94 +++++++++++++++++++ apps/site/src/lib/utm.ts | 52 ++++++++++ packages/ui/src/components/web-navigation.tsx | 41 ++++++-- 5 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 apps/site/src/components/utm-persistence.tsx create mode 100644 apps/site/src/lib/utm.ts diff --git a/apps/site/src/app/layout.tsx b/apps/site/src/app/layout.tsx index d7be4dd3cd..6ed6b3bbfb 100644 --- a/apps/site/src/app/layout.tsx +++ b/apps/site/src/app/layout.tsx @@ -15,6 +15,7 @@ import { import { Footer } from "@prisma-docs/ui/components/footer"; import { ThemeProvider } from "@prisma-docs/ui/components/theme-provider"; import { FontAwesomeScript as WebFA } from "@prisma/eclipse"; +import { UtmPersistence } from "@/components/utm-persistence"; const inter = Inter({ subsets: ["latin"], @@ -187,6 +188,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
+ ({ + utm_source: utm.source, + }); + + useEffect(() => { + const currentUtmParams = getUtmParams(new URLSearchParams(searchParams.toString())); + + if (currentUtmParams.utm_source) { + setStoredUtmParams(currentUtmParams); + writeStoredUtmParams(currentUtmParams); + return; + } + + const persistedUtmParams = readStoredUtmParams(); + setStoredUtmParams( + persistedUtmParams.utm_source + ? persistedUtmParams + : { utm_source: utm.source }, + ); + }, [searchParams, utm.source]); // Determine button variant based on pathname const getButtonVariant = (): ColorType => { @@ -62,7 +90,13 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) { return ( ); diff --git a/apps/site/src/components/utm-persistence.tsx b/apps/site/src/components/utm-persistence.tsx new file mode 100644 index 0000000000..653b13257a --- /dev/null +++ b/apps/site/src/components/utm-persistence.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; +import { + getUtmParams, + hasUtmParams, + mergeUtmParams, + readStoredUtmParams, + writeStoredUtmParams, +} from "@/lib/utm"; + +export function UtmPersistence() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + useEffect(() => { + const currentUtmParams = getUtmParams(new URLSearchParams(searchParams.toString())); + + if (hasUtmParams(currentUtmParams)) { + writeStoredUtmParams(currentUtmParams); + return; + } + + const storedUtmParams = readStoredUtmParams(); + + if (!hasUtmParams(storedUtmParams)) { + return; + } + + const currentUrl = new URL(window.location.href); + + if (!mergeUtmParams(currentUrl, storedUtmParams)) { + return; + } + + window.history.replaceState( + window.history.state, + "", + `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`, + ); + }, [pathname, searchParams]); + + useEffect(() => { + function handleClick(event: MouseEvent) { + const anchor = (event.target as HTMLElement).closest( + "a[href]", + ); + + if (!anchor) { + return; + } + + const href = anchor.getAttribute("href"); + + if ( + !href || + href.startsWith("#") || + href.startsWith("mailto:") || + href.startsWith("tel:") || + anchor.target === "_blank" || + anchor.hasAttribute("download") + ) { + return; + } + + const storedUtmParams = readStoredUtmParams(); + + if (!hasUtmParams(storedUtmParams)) { + return; + } + + const targetUrl = new URL(anchor.href, window.location.href); + + if (targetUrl.origin !== window.location.origin) { + return; + } + + if (!mergeUtmParams(targetUrl, storedUtmParams)) { + return; + } + + anchor.setAttribute( + "href", + `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`, + ); + } + + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + }, []); + + return null; +} diff --git a/apps/site/src/lib/utm.ts b/apps/site/src/lib/utm.ts new file mode 100644 index 0000000000..0324f24e35 --- /dev/null +++ b/apps/site/src/lib/utm.ts @@ -0,0 +1,52 @@ +export const UTM_STORAGE_KEY = "site_utm_params"; + +export type UtmParams = Record; + +export function getUtmParams(searchParams: URLSearchParams): UtmParams { + const utmParams: UtmParams = {}; + + for (const [key, value] of searchParams.entries()) { + if (key.startsWith("utm_") && value) { + utmParams[key] = value; + } + } + + return utmParams; +} + +export function hasUtmParams(utmParams: UtmParams) { + return Object.keys(utmParams).length > 0; +} + +export function mergeUtmParams(url: URL, utmParams: UtmParams) { + let updated = false; + + for (const [key, value] of Object.entries(utmParams)) { + if (!url.searchParams.has(key)) { + url.searchParams.set(key, value); + updated = true; + } + } + + return updated; +} + +export function readStoredUtmParams() { + const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY); + + if (!storedUtmParams) { + return {}; + } + + try { + return JSON.parse(storedUtmParams) as UtmParams; + } catch { + return {}; + } +} + +export function writeStoredUtmParams(utmParams: UtmParams) { + if (hasUtmParams(utmParams)) { + window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(utmParams)); + } +} diff --git a/packages/ui/src/components/web-navigation.tsx b/packages/ui/src/components/web-navigation.tsx index dfe9c29138..00a9b2d613 100644 --- a/packages/ui/src/components/web-navigation.tsx +++ b/packages/ui/src/components/web-navigation.tsx @@ -38,24 +38,51 @@ export interface Link { interface WebNavigationProps { links: Link[]; utm?: { - source: "website"; + source: string; medium: string; + campaign?: string; + content?: string; + term?: string; }; buttonVariant?: "ppg" | "orm" | undefined; } +function buildConsoleHref( + pathname: "/login" | "/sign-up", + utm?: WebNavigationProps["utm"], +) { + if (!utm) { + return `https://console.prisma.io${pathname}`; + } + + const href = new URL(`https://console.prisma.io${pathname}`); + + href.searchParams.set("utm_source", utm.source); + href.searchParams.set("utm_medium", utm.medium); + href.searchParams.set( + "utm_campaign", + utm.campaign || (pathname === "/login" ? "login" : "signup"), + ); + + if (utm.content) { + href.searchParams.set("utm_content", utm.content); + } + + if (utm.term) { + href.searchParams.set("utm_term", utm.term); + } + + return href.toString(); +} + export function WebNavigation({ links, utm, buttonVariant = "ppg", }: WebNavigationProps) { const [mobileView, setMobileView] = useState(false); - const loginHref = utm - ? `https://console.prisma.io/login?utm_source=${utm.source}&utm_medium=${utm.medium}&utm_campaign=login` - : "https://console.prisma.io/login"; - const signupHref = utm - ? `https://console.prisma.io/sign-up?utm_source=${utm.source}&utm_medium=${utm.medium}&utm_campaign=signup` - : "https://console.prisma.io/sign-up"; + const loginHref = buildConsoleHref("/login", utm); + const signupHref = buildConsoleHref("/sign-up", utm); useEffect(() => { if (mobileView) { From fdad040f510296828c6bd543ceffbf06ef8742f9 Mon Sep 17 00:00:00 2001 From: Mike Hartington Date: Tue, 7 Apr 2026 10:04:31 -0400 Subject: [PATCH 2/8] fix(site): failing build --- apps/site/src/components/navigation-wrapper.tsx | 9 +++++---- apps/site/src/components/utm-persistence.tsx | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx index 942aa8cd7a..ddbf062647 100644 --- a/apps/site/src/components/navigation-wrapper.tsx +++ b/apps/site/src/components/navigation-wrapper.tsx @@ -3,7 +3,7 @@ import { WebNavigation } from "@prisma-docs/ui/components/web-navigation"; import { Footer } from "@prisma-docs/ui/components/footer"; import { useEffect, useState } from "react"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname } from "next/navigation"; import { getUtmParams, readStoredUtmParams, @@ -56,13 +56,14 @@ function getUtmMedium(pathname: string) { export function NavigationWrapper({ links, utm }: NavigationWrapperProps) { const pathname = usePathname(); - const searchParams = useSearchParams(); const [storedUtmParams, setStoredUtmParams] = useState({ utm_source: utm.source, }); useEffect(() => { - const currentUtmParams = getUtmParams(new URLSearchParams(searchParams.toString())); + const currentUtmParams = getUtmParams( + new URLSearchParams(window.location.search), + ); if (currentUtmParams.utm_source) { setStoredUtmParams(currentUtmParams); @@ -76,7 +77,7 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) { ? persistedUtmParams : { utm_source: utm.source }, ); - }, [searchParams, utm.source]); + }, [pathname, utm.source]); // Determine button variant based on pathname const getButtonVariant = (): ColorType => { diff --git a/apps/site/src/components/utm-persistence.tsx b/apps/site/src/components/utm-persistence.tsx index 653b13257a..79035116c9 100644 --- a/apps/site/src/components/utm-persistence.tsx +++ b/apps/site/src/components/utm-persistence.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect } from "react"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname } from "next/navigation"; import { getUtmParams, hasUtmParams, @@ -12,10 +12,11 @@ import { export function UtmPersistence() { const pathname = usePathname(); - const searchParams = useSearchParams(); useEffect(() => { - const currentUtmParams = getUtmParams(new URLSearchParams(searchParams.toString())); + const currentUtmParams = getUtmParams( + new URLSearchParams(window.location.search), + ); if (hasUtmParams(currentUtmParams)) { writeStoredUtmParams(currentUtmParams); @@ -39,7 +40,7 @@ export function UtmPersistence() { "", `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`, ); - }, [pathname, searchParams]); + }, [pathname]); useEffect(() => { function handleClick(event: MouseEvent) { From b9c01d49fb3ba0207a6b5e5998cc0be5a394b0c1 Mon Sep 17 00:00:00 2001 From: Mike Hartington Date: Tue, 7 Apr 2026 10:10:41 -0400 Subject: [PATCH 3/8] 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 --- apps/blog/src/app/(blog)/layout.tsx | 2 +- .../src/components/navigation-wrapper.tsx | 32 +++++++-------- apps/site/src/lib/utm.ts | 41 ++++++++++++++++--- packages/ui/src/components/web-navigation.tsx | 28 +++++-------- 4 files changed, 61 insertions(+), 42 deletions(-) diff --git a/apps/blog/src/app/(blog)/layout.tsx b/apps/blog/src/app/(blog)/layout.tsx index 54ce2154d2..c43c9e6aa0 100644 --- a/apps/blog/src/app/(blog)/layout.tsx +++ b/apps/blog/src/app/(blog)/layout.tsx @@ -105,7 +105,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { {children}