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
6 changes: 4 additions & 2 deletions apps/blog/src/app/(blog)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -103,7 +104,8 @@ export function baseOptions() {
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider defaultTheme="system" storageKey="theme">
<WebNavigation
<UtmPersistence />
<NavigationWrapper
links={baseOptions().links}
utm={{ source: "website", medium: "blog" }}
/>
Expand Down
54 changes: 54 additions & 0 deletions apps/blog/src/components/navigation-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<WebNavigation
links={links}
utm={resolvedUtmParams}
preserveExactUtm={preserveExactUtm}
/>
);
}
98 changes: 98 additions & 0 deletions apps/blog/src/components/utm-persistence.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement>(
"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;
}
101 changes: 101 additions & 0 deletions apps/blog/src/lib/utm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
export const UTM_STORAGE_KEY = "blog_utm_params";
export const CONSOLE_HOST = "console.prisma.io";

export type UtmParams = Record<string, string>;

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.
}
}
21 changes: 15 additions & 6 deletions apps/site/src/app/(index)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand Down Expand Up @@ -114,17 +121,18 @@ export default function SiteHome() {
ship faster.
</p>
<div className="flex flex-col md:flex-row gap-4 items-center justify-center">
<Button
<ConsoleCtaButton
variant="ppg"
href="https://console.prisma.io/sign-up?utm_source=website&utm_medium=index&utm_campaign=cta"
consolePath="/sign-up"
defaultUtm={INDEX_CTA_DEFAULT_UTM}
size="3xl"
target="_blank"
rel="noopener noreferrer"
className="font-sans-display! font-[650]"
>
<span>Create database</span>
<i className="fa-regular fa-database ml-2" />
</Button>
</ConsoleCtaButton>
<CopyCode text="npx prisma init">
<span className="text-foreground-neutral-reverse-weak">$</span>
<span className="text-foreground-neutral-weak">
Expand Down Expand Up @@ -246,14 +254,15 @@ export default function SiteHome() {
</p>
</div>
<div className="flex flex-col gap-6 md:flex-row">
<Button
<ConsoleCtaButton
variant="ppg"
size="2xl"
href="https://console.prisma.io/sign-up?utm_source=website&utm_medium=index&utm_campaign=cta"
consolePath="/sign-up"
defaultUtm={INDEX_CTA_DEFAULT_UTM}
>
<span>Create your first Database</span>
<i className="fa-regular fa-arrow-right ml-2" />
</Button>
</ConsoleCtaButton>
<Button variant="default-stronger" size="2xl" href="/pricing">
<span>Explore Pricing</span>
<i className="fa-regular fa-arrow-right ml-2" />
Expand Down
2 changes: 2 additions & 0 deletions apps/site/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -187,6 +188,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<div className="bg-background-default absolute inset-0 -z-1 overflow-hidden" />
<Provider>
<ThemeProvider defaultTheme="system" storageKey="theme">
<UtmPersistence />
<NavigationWrapper
links={baseOptions().links}
utm={{ source: "website" }}
Expand Down
Loading
Loading