Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/blog/src/components/navigation-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ interface NavigationWrapperProps {

// UTM propagation, lifted verbatim out of @prisma-docs/ui's WebNavigation so
// swapping in the blog-local CF header does not change a single outbound URL.
// Outbound links only ever carry UTMs the visitor arrived with — no defaults
// are added (matching the site-wide drop of default console-CTA UTMs).
// Outbound links only ever carry UTMs the visitor arrived with — no default
// UTMs are added (matching the site-wide drop of default console-CTA UTMs).
// Visitors with no attribution at all still get `ref=prisma.io/blog/<slug>`
// on console links, stamped at click time by the document-level UtmPersistence
// listener; see `syncFallbackRef` in @prisma-docs/ui for why that is `ref` and
// not a UTM.
/** Appends the visitor's own `utm_*` params to an internal or absolute link. */
function buildHref(base: string, utm?: UtmParams) {
if (!utm) return base;
const isAbsolute = base.startsWith("http");
Expand All @@ -25,6 +30,7 @@ function buildHref(base: string, utm?: UtmParams) {
return isAbsolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`;
}

/** Builds a Console login or sign-up link carrying only the visitor's own `utm_*` params. */
function buildConsoleHref(pathname: "/login" | "/sign-up", utm?: UtmParams) {
if (!utm) return `https://console.prisma.io${pathname}`;

Expand All @@ -39,6 +45,7 @@ function buildConsoleHref(pathname: "/login" | "/sign-up", utm?: UtmParams) {
return href.toString();
}

/** Blog header with nav, logo, and Console links that preserve the visitor's UTMs. */
export function NavigationWrapper({ links }: NavigationWrapperProps) {
const [mounted, setMounted] = useState(false);

Expand Down
14 changes: 12 additions & 2 deletions apps/blog/src/components/utm-persistence.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
"use client";

import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence";
import { UTM_ATTRIBUTION_STORAGE_KEY } from "@prisma-docs/ui/lib/utm";
import { UTM_ATTRIBUTION_STORAGE_KEY, buildSiteRef } from "@prisma-docs/ui/lib/utm";

/**
* Blog attribution carrier. Client-routes only under `/blog`, and stamps
* `ref=prisma.io/blog/<slug>` on Console links for untagged visitors.
*/
export function UtmPersistence() {
return <SharedUtmPersistence storageKey={UTM_ATTRIBUTION_STORAGE_KEY} basePath="/blog" />;
return (
<SharedUtmPersistence
storageKey={UTM_ATTRIBUTION_STORAGE_KEY}
basePath="/blog"
fallbackConsoleRef={buildSiteRef}
/>
);
}
7 changes: 6 additions & 1 deletion apps/site/src/components/utm-persistence.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
"use client";

import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence";
import { UTM_ATTRIBUTION_STORAGE_KEY } from "@prisma-docs/ui/lib/utm";
import { UTM_ATTRIBUTION_STORAGE_KEY, buildSiteRef } from "@prisma-docs/ui/lib/utm";
import { CROSS_ZONE_PATHS } from "@/lib/zones";

/**
* Site-wide attribution carrier. Hard-navigates into the docs and blog zones,
* and stamps `ref=prisma.io/<page>` on Console links for untagged visitors.
*/
export function UtmPersistence() {
return (
<SharedUtmPersistence
storageKey={UTM_ATTRIBUTION_STORAGE_KEY}
proxiedPaths={CROSS_ZONE_PATHS}
fallbackConsoleRef={buildSiteRef}
/>
);
}
57 changes: 46 additions & 11 deletions packages/ui/src/components/utm-persistence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getUtmParams,
mergeUtmAttribution,
readStoredUtmAttribution,
syncFallbackRef,
syncUtmAttribution,
writeStoredUtmAttribution,
} from "../lib/utm";
Expand All @@ -28,8 +29,22 @@ interface UtmPersistenceProps {
proxiedPaths?: string[];
/** Local storage key for persisting first- and last-touch UTM params. */
storageKey: string;
/**
* Builds the `ref` stamped on Console links when the visitor has no stored
* or current campaign attribution, i.e. organic and direct traffic. Receives
* the current pathname. Omit it and untagged visitors reach the console with
* no attribution at all. See `syncFallbackRef` for why this is `ref` and not
* a `utm_source` default.
*/
fallbackConsoleRef?: (pathname: string) => string;
}

/**
* Reads the visitor's first- and last-touch attribution: stored touches merged
* with whatever campaign params are on the current URL. Persists and announces
* a touch only when it actually changed, and drops ad click IDs when analytics
* consent is absent. Returns `undefined` for a visitor with no attribution.
*/
export function getActiveAttribution(storageKey: string) {
const currentUtmParams = getUtmParams(new URLSearchParams(window.location.search), {
// Click IDs are advertising identifiers. Without analytics consent nothing
Expand Down Expand Up @@ -87,7 +102,19 @@ export function getActiveAttribution(storageKey: string) {
return attribution;
}

export function UtmPersistence({ basePath, proxiedPaths = [], storageKey }: UtmPersistenceProps) {
/**
* Document-level attribution carrier. On every route change it captures campaign
* params into storage; on every link click it rewrites the target so internal
* links keep the last touch and Console links also receive the first touch.
* With `fallbackConsoleRef`, Console links clicked by a visitor with no
* attribution at all get a `ref` naming the page that sent them.
*/
export function UtmPersistence({
basePath,
proxiedPaths = [],
storageKey,
fallbackConsoleRef,
}: UtmPersistenceProps) {
const pathname = usePathname();
const router = useRouter();

Expand All @@ -103,6 +130,7 @@ export function UtmPersistence({ basePath, proxiedPaths = [], storageKey }: UtmP
}, [pathname, storageKey]);

useEffect(() => {
/** Rewrites the clicked anchor's href with attribution before navigation. */
function handleClick(event: MouseEvent) {
if (event.defaultPrevented || event.button !== 0) {
return;
Expand All @@ -127,10 +155,6 @@ export function UtmPersistence({ basePath, proxiedPaths = [], storageKey }: UtmP
}

const attribution = getActiveAttribution(storageKey);
if (!attribution) {
return;
}

const targetUrl = new URL(anchor.href, window.location.href);
const isInternalLink = targetUrl.origin === window.location.origin;
const isConsoleLink = targetUrl.hostname === CONSOLE_HOST;
Expand All @@ -141,11 +165,22 @@ export function UtmPersistence({ basePath, proxiedPaths = [], storageKey }: UtmP
return;
}

if (
!syncUtmAttribution(targetUrl, attribution, {
includeFirstTouch: isConsoleLink || isConsoleRedirect,
})
) {
const isConsoleBound = isConsoleLink || isConsoleRedirect;

let updated: boolean;
if (attribution) {
updated = syncUtmAttribution(targetUrl, attribution, {
includeFirstTouch: isConsoleBound,
});
} else if (fallbackConsoleRef && isConsoleBound) {
// No campaign ever touched this visitor, so the only thing the console
// would learn is nothing. Tell it which page sent them instead.
updated = syncFallbackRef(targetUrl, fallbackConsoleRef(window.location.pathname));
} else {
return;
}

if (!updated) {
return;
}

Expand Down Expand Up @@ -179,7 +214,7 @@ export function UtmPersistence({ basePath, proxiedPaths = [], storageKey }: UtmP

document.addEventListener("click", handleClick, true);
return () => document.removeEventListener("click", handleClick, true);
}, [router, basePath, proxiedPaths, storageKey]);
}, [router, basePath, proxiedPaths, storageKey, fallbackConsoleRef]);

return null;
}
38 changes: 36 additions & 2 deletions packages/ui/src/lib/utm.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's TypeScript test runner requires the explicit extension.
import { mergeUtmAttribution, syncUtmAttribution, syncUtmParams } from "./utm.ts";
import {
buildSiteRef,
mergeUtmAttribution,
syncFallbackRef,
syncUtmAttribution,
syncUtmParams,
} from "./utm";

test("preserves first touch and replaces last touch", () => {
const firstVisit = mergeUtmAttribution(undefined, {
Expand Down Expand Up @@ -81,3 +86,32 @@ test("removes stale UTM parameters when forwarding a newer touch", () => {
assert.equal(url.searchParams.get("utm_source"), "rebrand-test");
assert.equal(url.searchParams.has("utm_campaign"), false);
});

test("names the handing-off page as the site ref", () => {
assert.equal(buildSiteRef("/"), "prisma.io");
assert.equal(buildSiteRef("/postgres"), "prisma.io/postgres");
assert.equal(buildSiteRef("/blog/some-post"), "prisma.io/blog/some-post");
});

test("stamps the fallback ref on an untagged Console link", () => {
const url = new URL("https://console.prisma.io/sign-up");

assert.equal(syncFallbackRef(url, "prisma.io/pricing"), true);
assert.equal(url.searchParams.get("ref"), "prisma.io/pricing");
assert.equal(url.searchParams.has("utm_source"), false);
});

test("never overrides a Console link that already carries attribution", () => {
for (const tagged of [
"https://console.prisma.io/sign-up?utm_source=docs&utm_medium=login",
"https://console.prisma.io/sign-up?ref=producthunt",
"https://console.prisma.io/sign-up?first_utm_source=x",
"https://console.prisma.io/sign-up?gclid=abc",
]) {
const url = new URL(tagged);
const before = url.toString();

assert.equal(syncFallbackRef(url, "prisma.io"), false, tagged);
assert.equal(url.toString(), before, tagged);
}
});
36 changes: 36 additions & 0 deletions packages/ui/src/lib/utm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,42 @@ export function syncUtmAttribution(
return updated;
}

/**
* The `ref` stamped on Console links for visitors who carry no campaign
* attribution: the prisma.io page that handed them off, e.g. `prisma.io/postgres`.
* The homepage is `prisma.io`. Shaped like the referrer domains partners already
* send in `ref` (`producthunt`, `laconsole.dev`), so it reads naturally next to
* them in the console's `signup_first_ref` / `signup_last_ref`.
*/
export function buildSiteRef(pathname: string) {
return `prisma.io${pathname === "/" ? "" : pathname}`;
}

/**
* Stamps `ref` on a Console link that carries no attribution at all.
*
* This is the fallback for organic and direct visitors. It deliberately uses
* `ref` and not `utm_source`: PostHog treats `utm_*` and ad click IDs as
* campaign parameters and freezes the first value it sees into the person's
* `$initial_utm_source`, which is what the removed `utm_source=website` default
* used to pollute. `ref` is not a campaign parameter, so the console still
* records it on the signup while the person's first-touch channel stays whatever
* it really was.
*
* A link that already carries any captured key or first-touch key is left
* alone: real attribution always wins over the fallback.
*/
export function syncFallbackRef(url: URL, ref: string) {
for (const key of url.searchParams.keys()) {
if (isCapturedKey(key) || key.startsWith("first_")) {
return false;
}
}

url.searchParams.set("ref", ref);
return true;
}

export function readStoredUtmAttribution(storageKey: string) {
if (typeof window === "undefined") {
return undefined;
Expand Down
Loading