diff --git a/apps/blog/src/components/navigation-wrapper.tsx b/apps/blog/src/components/navigation-wrapper.tsx index 8668683646..4a5887ce6f 100644 --- a/apps/blog/src/components/navigation-wrapper.tsx +++ b/apps/blog/src/components/navigation-wrapper.tsx @@ -2,7 +2,7 @@ import { WebNavigation } from "@prisma-docs/ui/components/web-navigation"; import { useEffect, useState } from "react"; -import { getUtmParams, hasUtmParams, type UtmParams } from "@/lib/utm"; +import { getUtmParams, hasUtmParams, type UtmParams } from "@prisma-docs/ui/lib/utm"; interface Link { text: string; diff --git a/apps/blog/src/components/utm-persistence.tsx b/apps/blog/src/components/utm-persistence.tsx index d9c041b659..933bc34327 100644 --- a/apps/blog/src/components/utm-persistence.tsx +++ b/apps/blog/src/components/utm-persistence.tsx @@ -1,116 +1,9 @@ "use client"; -import { useEffect } from "react"; -import { usePathname, useRouter } from "next/navigation"; -import { BLOG_PREFIX } from "@/lib/url"; -import { - clearStoredUtmParams, - CONSOLE_HOST, - getUtmParams, - hasUtmParams, - syncUtmParams, - writeStoredUtmParams, -} from "@/lib/utm"; +import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence"; 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 isBlogPath = - targetUrl.pathname === BLOG_PREFIX || - targetUrl.pathname.startsWith(`${BLOG_PREFIX}/`); - const isModifiedClick = - event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; - - if ( - isInternalLink && - isBlogPath && - anchor.target !== "_blank" && - !isModifiedClick - ) { - const internalPathname = - targetUrl.pathname === BLOG_PREFIX - ? "/" - : targetUrl.pathname.replace( - new RegExp(`^${BLOG_PREFIX}(?:/|$)`), - "/", - ); - event.preventDefault(); - router.push( - `${internalPathname}${targetUrl.search}${targetUrl.hash}`, - ); - return; - } - - anchor.setAttribute( - "href", - isInternalLink ? nextHref : targetUrl.toString(), - ); - } - - document.addEventListener("click", handleClick, true); - return () => document.removeEventListener("click", handleClick, true); - }, [router]); - - return null; + return ( + + ); } diff --git a/apps/docs/src/components/layout/link-item.tsx b/apps/docs/src/components/layout/link-item.tsx index e9c910d8c9..4b5ec09f45 100644 --- a/apps/docs/src/components/layout/link-item.tsx +++ b/apps/docs/src/components/layout/link-item.tsx @@ -1,9 +1,32 @@ 'use client'; -import type { ComponentProps, ReactNode } from 'react'; +import { type ComponentProps, type ReactNode, useEffect, useState } from 'react'; import { usePathname } from 'fumadocs-core/framework'; import { isActive, isActiveAny } from '../../lib/urls'; +import { getUtmParams, hasUtmParams } from '@prisma-docs/ui/lib/utm'; import Link from 'fumadocs-core/link'; +function useUtmHref(base: string): string { + const [href, setHref] = useState(base); + useEffect(() => { + const utm = getUtmParams(new URLSearchParams(window.location.search)); + if (!hasUtmParams(utm)) { + setHref(base); + return; + } + try { + const isAbsolute = base.startsWith('http'); + const url = isAbsolute ? new URL(base) : new URL(base, 'https://n.co'); + for (const [key, value] of Object.entries(utm)) { + url.searchParams.set(key, value); + } + setHref(isAbsolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`); + } catch { + setHref(base); + } + }, [base]); + return href; +} + interface Filterable { /** * Restrict where the item is displayed @@ -111,8 +134,10 @@ export function LinkItem({ ? isActiveAny(item.activePaths, pathname) : activeType !== 'none' && isActive(item.url, pathname, activeType === 'nested-url'); + const href = useUtmHref(item.url); + return ( - + {props.children} ); diff --git a/apps/docs/src/components/provider.tsx b/apps/docs/src/components/provider.tsx index e7eb276d32..4b0ec89ed4 100644 --- a/apps/docs/src/components/provider.tsx +++ b/apps/docs/src/components/provider.tsx @@ -7,6 +7,7 @@ import type { ReactNode } from "react"; import { source } from "@/lib/source"; import { TreeContextProvider } from "fumadocs-ui/contexts/tree"; import { TrackingProvider } from "@/components/tracking-provider"; +import { UtmPersistence } from "@/components/utm-persistence"; const KAPA_INTEGRATION_ID = "1b51bb03-43cc-4ef4-95f1-93288a91b560"; @@ -29,6 +30,7 @@ export function Provider({ children }: { children: ReactNode }) { }} > + {children} diff --git a/apps/docs/src/components/utm-persistence.tsx b/apps/docs/src/components/utm-persistence.tsx new file mode 100644 index 0000000000..1ac5a3834e --- /dev/null +++ b/apps/docs/src/components/utm-persistence.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence"; + +export function UtmPersistence() { + return ( + + ); +} diff --git a/apps/site/src/components/console-cta-button.tsx b/apps/site/src/components/console-cta-button.tsx index 05a2114dcc..628a46ff7f 100644 --- a/apps/site/src/components/console-cta-button.tsx +++ b/apps/site/src/components/console-cta-button.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Button, type ButtonProps } from "@prisma/eclipse"; -import { getUtmParams, hasUtmParams, type UtmParams } from "@/lib/utm"; +import { getUtmParams, hasUtmParams, type UtmParams } from "@prisma-docs/ui/lib/utm"; interface ConsoleCtaButtonProps extends Omit { consolePath: "/login" | "/sign-up"; diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx index 2e330333e2..2a1d6329aa 100644 --- a/apps/site/src/components/navigation-wrapper.tsx +++ b/apps/site/src/components/navigation-wrapper.tsx @@ -8,7 +8,7 @@ import { getUtmParams, hasUtmParams, type UtmParams, -} from "@/lib/utm"; +} from "@prisma-docs/ui/lib/utm"; interface Link { text: string; diff --git a/apps/site/src/components/utm-persistence.tsx b/apps/site/src/components/utm-persistence.tsx index 31c204b494..739978813f 100644 --- a/apps/site/src/components/utm-persistence.tsx +++ b/apps/site/src/components/utm-persistence.tsx @@ -1,113 +1,14 @@ "use client"; -import { useEffect } from "react"; -import { usePathname, useRouter } from "next/navigation"; -import { - clearStoredUtmParams, - CONSOLE_HOST, - getUtmParams, - hasUtmParams, - syncUtmParams, - writeStoredUtmParams, -} from "@/lib/utm"; +import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence"; -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 PROXIED_PATHS = ["/docs", "/blog"]; - 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; - - // Paths proxied to other apps via rewrites — must use full navigation - // so the server-side rewrite kicks in instead of client-side routing. - const isProxiedPath = - targetUrl.pathname === "/docs" || - targetUrl.pathname.startsWith("/docs/") || - targetUrl.pathname === "/blog" || - targetUrl.pathname.startsWith("/blog/"); - - if ( - isInternalLink && - !isProxiedPath && - 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; +export function UtmPersistence() { + return ( + + ); } diff --git a/apps/site/src/lib/utm.ts b/apps/site/src/lib/utm.ts deleted file mode 100644 index b3877d06c6..0000000000 --- a/apps/site/src/lib/utm.ts +++ /dev/null @@ -1,101 +0,0 @@ -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/package.json b/packages/ui/package.json index c6ff4e0dc3..d24a89c96b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -34,6 +34,7 @@ "typescript": "catalog:" }, "peerDependencies": { + "next": "catalog:", "react": "^19.2.0", "react-dom": "^19.2.0" } diff --git a/packages/ui/src/components/utm-persistence.tsx b/packages/ui/src/components/utm-persistence.tsx new file mode 100644 index 0000000000..ec60dfaf1a --- /dev/null +++ b/packages/ui/src/components/utm-persistence.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { + clearStoredUtmParams, + CONSOLE_HOST, + getUtmParams, + hasUtmParams, + syncUtmParams, + writeStoredUtmParams, +} from "../lib/utm"; + +interface UtmPersistenceProps { + /** + * The base path this app owns (e.g. "/blog", "/docs"). + * Only paths under this prefix use client-side router.push(). + * Omit for the root app (no basePath). + */ + basePath?: string; + /** + * Paths that are proxied to other apps via server rewrites. + * These always use full page navigation instead of router.push(). + * Only relevant for the root app (no basePath). + */ + proxiedPaths?: string[]; + /** Session storage key for persisting UTM params. */ + storageKey: string; +} + +export function UtmPersistence({ + basePath, + proxiedPaths = [], + storageKey, +}: UtmPersistenceProps) { + const pathname = usePathname(); + const router = useRouter(); + + useEffect(() => { + const currentUtmParams = getUtmParams( + new URLSearchParams(window.location.search), + ); + + if (hasUtmParams(currentUtmParams)) { + writeStoredUtmParams(storageKey, currentUtmParams); + return; + } + + clearStoredUtmParams(storageKey); + }, [pathname, storageKey]); + + 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) { + const canClientRoute = basePath + ? targetUrl.pathname === basePath || + targetUrl.pathname.startsWith(`${basePath}/`) + : !proxiedPaths.some( + (p) => + targetUrl.pathname === p || + targetUrl.pathname.startsWith(`${p}/`), + ); + + if (canClientRoute) { + const internalPathname = basePath + ? targetUrl.pathname === basePath + ? "/" + : targetUrl.pathname.startsWith(`${basePath}/`) + ? targetUrl.pathname.slice(basePath.length) + : targetUrl.pathname + : targetUrl.pathname; + + event.preventDefault(); + router.push( + `${internalPathname}${targetUrl.search}${targetUrl.hash}`, + ); + return; + } + } + + anchor.setAttribute( + "href", + isInternalLink ? nextHref : targetUrl.toString(), + ); + } + + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + }, [router, basePath, proxiedPaths]); + + return null; +} diff --git a/packages/ui/src/components/web-navigation.tsx b/packages/ui/src/components/web-navigation.tsx index 1b03b10f20..cc3188d45e 100644 --- a/packages/ui/src/components/web-navigation.tsx +++ b/packages/ui/src/components/web-navigation.tsx @@ -42,6 +42,18 @@ interface WebNavigationProps { buttonVariant?: "ppg" | "orm" | undefined; } +function buildHref(base: string, utm?: WebNavigationProps["utm"]) { + if (!utm) return base; + const isAbsolute = base.startsWith("http"); + const url = isAbsolute ? new URL(base) : new URL(base, "https://n.co"); + for (const [key, value] of Object.entries(utm)) { + if (key.startsWith("utm_") && value) { + url.searchParams.set(key, value); + } + } + return isAbsolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`; +} + function buildConsoleHref( pathname: "/login" | "/sign-up", utm?: WebNavigationProps["utm"], @@ -78,6 +90,19 @@ export function WebNavigation({ const [mobileView, setMobileView] = useState(false); const loginHref = buildConsoleHref("/login", utm, preserveExactUtm); const signupHref = buildConsoleHref("/sign-up", utm, preserveExactUtm); + const logoHref = preserveExactUtm + ? buildHref("https://www.prisma.io", utm) + : "https://www.prisma.io"; + const resolvedLinks = preserveExactUtm + ? links.map((link) => ({ + ...link, + url: link.url && !link.external ? buildHref(link.url, utm) : link.url, + sub: link.sub?.map((sub) => ({ + ...sub, + url: sub.external ? sub.url : buildHref(sub.url, utm), + })), + })) + : links; useEffect(() => { if (mobileView) { @@ -94,13 +119,13 @@ export function WebNavigation({ {Logo}
- {links.map((link) => + {resolvedLinks.map((link) => link.url ? ( @@ -155,7 +180,7 @@ export function WebNavigation({ {mobileView && ( ; @@ -52,13 +51,13 @@ export function syncUtmParams(url: URL, utmParams: UtmParams) { return updated; } -export function readStoredUtmParams() { +export function readStoredUtmParams(storageKey: string) { if (typeof window === "undefined") { return {}; } try { - const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY); + const storedUtmParams = window.sessionStorage.getItem(storageKey); if (!storedUtmParams) { return {}; @@ -70,7 +69,7 @@ export function readStoredUtmParams() { } } -export function writeStoredUtmParams(utmParams: UtmParams) { +export function writeStoredUtmParams(storageKey: string, utmParams: UtmParams) { if (typeof window === "undefined") { return; } @@ -82,19 +81,19 @@ export function writeStoredUtmParams(utmParams: UtmParams) { } try { - window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(validUtmParams)); + window.sessionStorage.setItem(storageKey, JSON.stringify(validUtmParams)); } catch { // Ignore storage failures in restricted environments. } } -export function clearStoredUtmParams() { +export function clearStoredUtmParams(storageKey: string) { if (typeof window === "undefined") { return; } try { - window.sessionStorage.removeItem(UTM_STORAGE_KEY); + window.sessionStorage.removeItem(storageKey); } catch { // Ignore storage failures in restricted environments. } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d31c9f567..64b5b7b111 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -818,6 +818,9 @@ importers: lucide-react: specifier: 'catalog:' version: 0.575.0(react@19.2.4) + next: + specifier: 'catalog:' + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.2.0 version: 19.2.4