diff --git a/apps/blog/src/app/(blog)/layout.tsx b/apps/blog/src/app/(blog)/layout.tsx index 54ce2154d2..9e69c85883 100644 --- a/apps/blog/src/app/(blog)/layout.tsx +++ b/apps/blog/src/app/(blog)/layout.tsx @@ -1,6 +1,7 @@ -import { WebNavigation } from "@prisma-docs/ui/components/web-navigation"; import { Footer } from "@prisma-docs/ui/components/footer"; import { ThemeProvider } from "@prisma-docs/ui/components/theme-provider"; +import { NavigationWrapper } from "@/components/navigation-wrapper"; +import { UtmPersistence } from "@/components/utm-persistence"; export function baseOptions() { return { nav: { @@ -103,7 +104,8 @@ export function baseOptions() { export default function Layout({ children }: { children: React.ReactNode }) { return ( - + diff --git a/apps/blog/src/components/navigation-wrapper.tsx b/apps/blog/src/components/navigation-wrapper.tsx new file mode 100644 index 0000000000..8668683646 --- /dev/null +++ b/apps/blog/src/components/navigation-wrapper.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { WebNavigation } from "@prisma-docs/ui/components/web-navigation"; +import { useEffect, useState } from "react"; +import { getUtmParams, hasUtmParams, type UtmParams } from "@/lib/utm"; + +interface Link { + text: string; + external?: boolean; + url?: string; + icon?: string; + desc?: string; + col?: number; + sub?: Array<{ + text: string; + external?: boolean; + url: string; + icon?: string; + desc?: string; + }>; +} + +interface NavigationWrapperProps { + links: Link[]; + utm: { + source: string; + medium: string; + }; +} + +export function NavigationWrapper({ links, utm }: NavigationWrapperProps) { + const [mounted, setMounted] = useState(false); + const defaultUtmParams = { + utm_source: utm.source, + utm_medium: utm.medium, + }; + + useEffect(() => { + setMounted(true); + }, []); + + const currentUtmParams: UtmParams = + mounted ? getUtmParams(new URLSearchParams(window.location.search)) : {}; + const preserveExactUtm = hasUtmParams(currentUtmParams); + const resolvedUtmParams = preserveExactUtm ? currentUtmParams : defaultUtmParams; + + return ( + + ); +} diff --git a/apps/blog/src/components/utm-persistence.tsx b/apps/blog/src/components/utm-persistence.tsx new file mode 100644 index 0000000000..4d2b80d60c --- /dev/null +++ b/apps/blog/src/components/utm-persistence.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { + clearStoredUtmParams, + CONSOLE_HOST, + getUtmParams, + hasUtmParams, + syncUtmParams, + writeStoredUtmParams, +} from "@/lib/utm"; + +export function UtmPersistence() { + const pathname = usePathname(); + const router = useRouter(); + + useEffect(() => { + const currentUtmParams = getUtmParams( + new URLSearchParams(window.location.search), + ); + + if (hasUtmParams(currentUtmParams)) { + writeStoredUtmParams(currentUtmParams); + return; + } + + clearStoredUtmParams(); + }, [pathname]); + + useEffect(() => { + function handleClick(event: MouseEvent) { + if (event.defaultPrevented || event.button !== 0) { + return; + } + + 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.hasAttribute("download") + ) { + return; + } + + const activeUtmParams = getUtmParams( + new URLSearchParams(window.location.search), + ); + + if (!hasUtmParams(activeUtmParams)) { + return; + } + + const targetUrl = new URL(anchor.href, window.location.href); + const isInternalLink = targetUrl.origin === window.location.origin; + const isConsoleLink = targetUrl.hostname === CONSOLE_HOST; + + if (!isInternalLink && !isConsoleLink) { + return; + } + + if (!syncUtmParams(targetUrl, activeUtmParams)) { + return; + } + + const nextHref = `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`; + const isModifiedClick = + event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + + if (isInternalLink && anchor.target !== "_blank" && !isModifiedClick) { + event.preventDefault(); + router.push(nextHref); + return; + } + + anchor.setAttribute( + "href", + isInternalLink ? nextHref : targetUrl.toString(), + ); + } + + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + }, [router]); + + return null; +} diff --git a/apps/blog/src/lib/utm.ts b/apps/blog/src/lib/utm.ts new file mode 100644 index 0000000000..8e79844c15 --- /dev/null +++ b/apps/blog/src/lib/utm.ts @@ -0,0 +1,101 @@ +export const UTM_STORAGE_KEY = "blog_utm_params"; +export const CONSOLE_HOST = "console.prisma.io"; + +export type UtmParams = Record; + +function sanitizeUtmParams(input: unknown): UtmParams { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return {}; + } + + return Object.fromEntries( + Object.entries(input).filter( + ([key, value]) => + key.startsWith("utm_") && typeof value === "string" && value.length > 0, + ), + ); +} + +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 syncUtmParams(url: URL, utmParams: UtmParams) { + let updated = false; + + for (const key of Array.from(url.searchParams.keys())) { + if (key.startsWith("utm_") && !(key in utmParams)) { + url.searchParams.delete(key); + updated = true; + } + } + + for (const [key, value] of Object.entries(utmParams)) { + if (url.searchParams.get(key) !== value) { + url.searchParams.set(key, value); + updated = true; + } + } + + return updated; +} + +export function readStoredUtmParams() { + if (typeof window === "undefined") { + return {}; + } + + try { + const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY); + + if (!storedUtmParams) { + return {}; + } + + return sanitizeUtmParams(JSON.parse(storedUtmParams)); + } catch { + return {}; + } +} + +export function writeStoredUtmParams(utmParams: UtmParams) { + if (typeof window === "undefined") { + return; + } + + const validUtmParams = sanitizeUtmParams(utmParams); + + if (!hasUtmParams(validUtmParams)) { + return; + } + + try { + window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(validUtmParams)); + } catch { + // Ignore storage failures in restricted environments. + } +} + +export function clearStoredUtmParams() { + if (typeof window === "undefined") { + return; + } + + try { + window.sessionStorage.removeItem(UTM_STORAGE_KEY); + } catch { + // Ignore storage failures in restricted environments. + } +} diff --git a/apps/site/src/app/(index)/page.tsx b/apps/site/src/app/(index)/page.tsx index e030b61f14..06016b1a18 100644 --- a/apps/site/src/app/(index)/page.tsx +++ b/apps/site/src/app/(index)/page.tsx @@ -5,9 +5,16 @@ import { Button } from "@prisma/eclipse"; import { CopyCode } from "@/components/homepage/copy-btn"; import { Bento } from "@/components/homepage/bento"; import { CardSection } from "@/components/homepage/card-section/card-section"; +import { ConsoleCtaButton } from "@/components/console-cta-button"; import review from "../../data/homepage.json"; import Testimonials from "../../components/homepage/testimonials"; +const INDEX_CTA_DEFAULT_UTM = { + utm_source: "website", + utm_medium: "index", + utm_campaign: "cta", +} as const; + const twoCol = [ { content: ( @@ -114,9 +121,10 @@ export default function SiteHome() { ship faster.

- + $ @@ -246,14 +254,15 @@ export default function SiteHome() {

- + + ); +} diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx index b9b325f39b..2e330333e2 100644 --- a/apps/site/src/components/navigation-wrapper.tsx +++ b/apps/site/src/components/navigation-wrapper.tsx @@ -2,7 +2,13 @@ import { WebNavigation } from "@prisma-docs/ui/components/web-navigation"; import { Footer } from "@prisma-docs/ui/components/footer"; +import { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; +import { + getUtmParams, + hasUtmParams, + type UtmParams, +} from "@/lib/utm"; interface Link { text: string; @@ -23,7 +29,7 @@ interface Link { interface NavigationWrapperProps { links: Link[]; utm: { - source: "website"; + source: string; }; } @@ -49,6 +55,20 @@ function getUtmMedium(pathname: string) { export function NavigationWrapper({ links, utm }: NavigationWrapperProps) { const pathname = usePathname(); + const [mounted, setMounted] = useState(false); + const defaultUtmParams = { + utm_source: utm.source, + utm_medium: getUtmMedium(pathname), + }; + + useEffect(() => { + setMounted(true); + }, []); + + const currentUtmParams: UtmParams = + mounted ? getUtmParams(new URLSearchParams(window.location.search)) : {}; + const preserveExactUtm = hasUtmParams(currentUtmParams); + const resolvedUtmParams = preserveExactUtm ? currentUtmParams : defaultUtmParams; // Determine button variant based on pathname const getButtonVariant = (): ColorType => { @@ -62,7 +82,8 @@ 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..d0c1c90c76 --- /dev/null +++ b/apps/site/src/components/utm-persistence.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { + clearStoredUtmParams, + CONSOLE_HOST, + getUtmParams, + hasUtmParams, + syncUtmParams, + writeStoredUtmParams, +} from "@/lib/utm"; + +export function UtmPersistence() { + const pathname = usePathname(); + const router = useRouter(); + + useEffect(() => { + const currentUtmParams = getUtmParams( + new URLSearchParams(window.location.search), + ); + + if (hasUtmParams(currentUtmParams)) { + writeStoredUtmParams(currentUtmParams); + return; + } + + clearStoredUtmParams(); + }, [pathname]); + + useEffect(() => { + function handleClick(event: MouseEvent) { + if (event.defaultPrevented || event.button !== 0) { + return; + } + + 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.hasAttribute("download") + ) { + return; + } + + const activeUtmParams = getUtmParams( + new URLSearchParams(window.location.search), + ); + + if (!hasUtmParams(activeUtmParams)) { + return; + } + + const targetUrl = new URL(anchor.href, window.location.href); + const isInternalLink = targetUrl.origin === window.location.origin; + const isConsoleLink = targetUrl.hostname === CONSOLE_HOST; + + if (!isInternalLink && !isConsoleLink) { + return; + } + + const updated = syncUtmParams(targetUrl, activeUtmParams); + + if (!updated) { + return; + } + + const nextHref = `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`; + const isModifiedClick = + event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + + if (isInternalLink && anchor.target !== "_blank" && !isModifiedClick) { + event.preventDefault(); + router.push(nextHref); + return; + } + + anchor.setAttribute( + "href", + isInternalLink ? nextHref : targetUrl.toString(), + ); + } + + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + }, [router]); + + return null; +} diff --git a/apps/site/src/lib/utm.ts b/apps/site/src/lib/utm.ts new file mode 100644 index 0000000000..b3877d06c6 --- /dev/null +++ b/apps/site/src/lib/utm.ts @@ -0,0 +1,101 @@ +export const UTM_STORAGE_KEY = "site_utm_params"; +export const CONSOLE_HOST = "console.prisma.io"; + +export type UtmParams = Record; + +function sanitizeUtmParams(input: unknown): UtmParams { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return {}; + } + + return Object.fromEntries( + Object.entries(input).filter( + ([key, value]) => + key.startsWith("utm_") && typeof value === "string" && value.length > 0, + ), + ); +} + +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 syncUtmParams(url: URL, utmParams: UtmParams) { + let updated = false; + + for (const key of Array.from(url.searchParams.keys())) { + if (key.startsWith("utm_") && !(key in utmParams)) { + url.searchParams.delete(key); + updated = true; + } + } + + for (const [key, value] of Object.entries(utmParams)) { + if (url.searchParams.get(key) !== value) { + url.searchParams.set(key, value); + updated = true; + } + } + + return updated; +} + +export function readStoredUtmParams() { + if (typeof window === "undefined") { + return {}; + } + + try { + const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY); + + if (!storedUtmParams) { + return {}; + } + + return sanitizeUtmParams(JSON.parse(storedUtmParams)); + } catch { + return {}; + } +} + +export function writeStoredUtmParams(utmParams: UtmParams) { + if (typeof window === "undefined") { + return; + } + + const validUtmParams = sanitizeUtmParams(utmParams); + + if (!hasUtmParams(validUtmParams)) { + return; + } + + try { + window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(validUtmParams)); + } catch { + // Ignore storage failures in restricted environments. + } +} + +export function clearStoredUtmParams() { + if (typeof window === "undefined") { + return; + } + + try { + window.sessionStorage.removeItem(UTM_STORAGE_KEY); + } catch { + // Ignore storage failures in restricted environments. + } +} diff --git a/packages/ui/src/components/web-navigation.tsx b/packages/ui/src/components/web-navigation.tsx index dfe9c29138..2ae536e5dc 100644 --- a/packages/ui/src/components/web-navigation.tsx +++ b/packages/ui/src/components/web-navigation.tsx @@ -37,25 +37,47 @@ export interface Link { interface WebNavigationProps { links: Link[]; - utm?: { - source: "website"; - medium: string; - }; + utm?: Record; + preserveExactUtm?: boolean; buttonVariant?: "ppg" | "orm" | undefined; } +function buildConsoleHref( + pathname: "/login" | "/sign-up", + utm?: WebNavigationProps["utm"], + preserveExactUtm = false, +) { + if (!utm) { + return `https://console.prisma.io${pathname}`; + } + + const href = new URL(`https://console.prisma.io${pathname}`); + + for (const [key, value] of Object.entries(utm)) { + if (key.startsWith("utm_") && value) { + href.searchParams.set(key, value); + } + } + + if (!preserveExactUtm && !href.searchParams.has("utm_campaign")) { + href.searchParams.set( + "utm_campaign", + pathname === "/login" ? "login" : "signup", + ); + } + + return href.toString(); +} + export function WebNavigation({ links, utm, + preserveExactUtm = false, 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, preserveExactUtm); + const signupHref = buildConsoleHref("/sign-up", utm, preserveExactUtm); useEffect(() => { if (mobileView) {