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
54 changes: 54 additions & 0 deletions apps/web/__tests__/api/cookie-consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { POST } from "@/app/api/cookie-consent/route";
import {
COOKIE_CONSENT_COOKIE,
COOKIE_CONSENT_MAX_AGE,
serializeCookieConsent,
} from "@/lib/cookie-consent";

function makeRequest(body: unknown) {
return new NextRequest(new URL("/api/cookie-consent", "http://localhost"), {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}

describe("POST /api/cookie-consent", () => {
it("sets an essential-only consent cookie", async () => {
const response = await POST(makeRequest({ preference: "essential" }));
const json = await response.json();
const setCookie = response.headers.get("set-cookie") ?? "";

expect(response.status).toBe(200);
expect(json).toEqual({ preference: "essential", analytics: false });
expect(setCookie).toContain(
`${COOKIE_CONSENT_COOKIE}=${serializeCookieConsent("essential")}`,
);
expect(setCookie).toContain(`Max-Age=${COOKIE_CONSENT_MAX_AGE}`);
expect(setCookie).toContain("Path=/");
expect(setCookie.toLowerCase()).toContain("samesite=lax");
});

it("sets analytics consent when all cookies are accepted", async () => {
const response = await POST(makeRequest({ preference: "all" }));
const json = await response.json();
const setCookie = response.headers.get("set-cookie") ?? "";

expect(response.status).toBe(200);
expect(json).toEqual({ preference: "all", analytics: true });
expect(setCookie).toContain(
`${COOKIE_CONSENT_COOKIE}=${serializeCookieConsent("all")}`,
);
});

it("rejects invalid preferences", async () => {
const response = await POST(makeRequest({ preference: "marketing" }));
const json = await response.json();

expect(response.status).toBe(400);
expect(json.error).toBe("Invalid cookie preference");
expect(response.headers.get("set-cookie")).toBeNull();
});
});
45 changes: 45 additions & 0 deletions apps/web/__tests__/unit/cookie-consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
COOKIE_CONSENT_COOKIE,
getCookieConsentFromCookieString,
parseCookieConsent,
serializeCookieConsent,
} from "@/lib/cookie-consent";

describe("cookie consent helpers", () => {
it("serializes and parses essential-only consent", () => {
const value = serializeCookieConsent("essential");

expect(parseCookieConsent(value)).toEqual({
preference: "essential",
analytics: false,
});
});

it("serializes and parses analytics consent", () => {
const value = serializeCookieConsent("all");

expect(parseCookieConsent(value)).toEqual({
preference: "all",
analytics: true,
});
});

it("extracts consent from a browser cookie string", () => {
const value = serializeCookieConsent("essential");

expect(
getCookieConsentFromCookieString(
`theme=dark; ${COOKIE_CONSENT_COOKIE}=${value}; ref=alice`,
),
).toEqual({
preference: "essential",
analytics: false,
});
});

it("ignores unknown consent values", () => {
expect(parseCookieConsent("v0-all")).toBeNull();
expect(getCookieConsentFromCookieString("theme=dark")).toBeNull();
});
});
1 change: 0 additions & 1 deletion apps/web/app/(landing)/join/[username]/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ export default async function Image({
flexDirection: "column",
alignItems: "center",
position: "relative",
zIndex: 1,
}}
>
{/* Avatar */}
Expand Down
17 changes: 16 additions & 1 deletion apps/web/app/(landing)/join/[username]/ref-cookie.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
"use client";

import { useEffect } from "react";
import {
COOKIE_CONSENT_EVENT,
getCookieConsentFromCookieString,
} from "@/lib/cookie-consent";

export function RefCookie({ username }: { username: string }) {
useEffect(() => {
document.cookie = `ref=${encodeURIComponent(username)}; path=/; max-age=${30 * 24 * 60 * 60}; samesite=lax`;
function setRefCookie() {
document.cookie = `ref=${encodeURIComponent(username)}; path=/; max-age=${
30 * 24 * 60 * 60
}; samesite=lax`;
}

if (getCookieConsentFromCookieString(document.cookie)) {
setRefCookie();
}

window.addEventListener(COOKIE_CONSENT_EVENT, setRefCookie);
return () => window.removeEventListener(COOKIE_CONSENT_EVENT, setRefCookie);
}, [username]);

return null;
Expand Down
8 changes: 7 additions & 1 deletion apps/web/app/(landing)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { CookieConsentModal } from "@/components/landing/CookieConsentModal";

export const metadata: Metadata = {
title: { absolute: "Straude — Strava for Claude Code" },
Expand All @@ -11,5 +12,10 @@ export default function LandingLayout({
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
return (
<>
{children}
<CookieConsentModal />
</>
);
}
14 changes: 10 additions & 4 deletions apps/web/app/(landing)/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function PrivacyPage() {
Privacy Policy
</h1>
<p className="mt-2 text-sm text-muted">
Last updated: April 8, 2026
Last updated: June 2, 2026
</p>

<div className="mt-10 space-y-8 text-[0.9375rem] leading-relaxed text-foreground/80">
Expand Down Expand Up @@ -81,8 +81,8 @@ export default function PrivacyPage() {
session count) — never prompts, code, or conversation content
</li>
<li>
<strong>Analytics:</strong> page views and basic interaction
data via Vercel Analytics
<strong>Analytics, when you opt in:</strong> page views and
basic interaction data via Vercel Analytics and PostHog
</li>
</ul>
</section>
Expand Down Expand Up @@ -135,6 +135,9 @@ export default function PrivacyPage() {
<li>
<strong>Vercel:</strong> application hosting and analytics
</li>
<li>
<strong>PostHog:</strong> opt-in product analytics
</li>
<li>
<strong>GitHub:</strong> OAuth authentication (if you sign in
with GitHub)
Expand All @@ -159,7 +162,10 @@ export default function PrivacyPage() {
</h2>
<p className="mt-2">
We use essential cookies for authentication and session
management. We do not use third-party tracking cookies.
management through Supabase Auth, security, referral
attribution, and storing your cookie preference. Analytics
stays off unless you choose to accept all cookies. We do not use
third-party tracking cookies.
</p>
</section>

Expand Down
44 changes: 44 additions & 0 deletions apps/web/app/api/cookie-consent/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import {
COOKIE_CONSENT_COOKIE,
COOKIE_CONSENT_MAX_AGE,
type CookieConsentPreference,
serializeCookieConsent,
} from "@/lib/cookie-consent";

const VALID_PREFERENCES = new Set<CookieConsentPreference>([
"essential",
"all",
]);

export async function POST(request: NextRequest) {
let body: { preference?: unknown };

try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const preference = body.preference;
if (typeof preference !== "string" || !VALID_PREFERENCES.has(preference as CookieConsentPreference)) {
return NextResponse.json({ error: "Invalid cookie preference" }, { status: 400 });
}

const typedPreference = preference as CookieConsentPreference;
const response = NextResponse.json({
preference: typedPreference,
analytics: typedPreference === "all",
});

response.cookies.set({
name: COOKIE_CONSENT_COOKIE,
value: serializeCookieConsent(typedPreference),
path: "/",
maxAge: COOKIE_CONSENT_MAX_AGE,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
});

return response;
}
4 changes: 2 additions & 2 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Metadata, Viewport } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { Analytics } from "@vercel/analytics/next";
import { Agentation } from "agentation";
import Script from "next/script";
import { ConsentAwareAnalytics } from "@/components/providers/ConsentAwareAnalytics";
import { PostHogClientProvider } from "@/components/providers/PostHogProvider";
import { QueryProvider } from "@/components/providers/QueryProvider";
import { ThemeProvider } from "@/components/providers/ThemeProvider";
Expand Down Expand Up @@ -131,7 +131,7 @@ export default function RootLayout({
<ThemeProvider>{children}</ThemeProvider>
</QueryProvider>
</PostHogClientProvider>
<Analytics />
<ConsentAwareAnalytics />
{process.env.NODE_ENV === "development" && <Agentation />}
</body>
</html>
Expand Down
1 change: 0 additions & 1 deletion apps/web/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ export default async function Image() {
alignItems: "center",
justifyContent: "center",
position: "relative",
zIndex: 1,
}}
>
{/* Logo: orange trapezoid */}
Expand Down
114 changes: 114 additions & 0 deletions apps/web/components/landing/CookieConsentModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use client";

import { useEffect, useState } from "react";
import Link from "next/link";
import { Dialog } from "@base-ui-components/react/dialog";
import { ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/Button";
import {
COOKIE_CONSENT_EVENT,
type CookieConsentPreference,
getCookieConsentFromCookieString,
} from "@/lib/cookie-consent";

export function CookieConsentModal() {
const [checked, setChecked] = useState(false);
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState<CookieConsentPreference | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
setOpen(getCookieConsentFromCookieString(document.cookie) === null);
setChecked(true);
}, []);

async function savePreference(preference: CookieConsentPreference) {
setSaving(preference);
setError(null);

try {
const response = await fetch("/api/cookie-consent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ preference }),
});

if (!response.ok) {
throw new Error("Failed to save cookie preference");
}

window.dispatchEvent(
new CustomEvent(COOKIE_CONSENT_EVENT, {
detail: {
preference,
analytics: preference === "all",
},
}),
);
setOpen(false);
} catch {
setError("Could not save your preference. Please try again.");
} finally {
setSaving(null);
}
}

if (!checked || !open) return null;

return (
<Dialog.Root open modal={false}>
<Dialog.Portal>
<Dialog.Popup className="fixed inset-x-4 bottom-4 z-[100] mx-auto w-auto max-w-lg rounded-md border border-landing-border bg-landing-surface p-4 text-landing-text shadow-2xl sm:bottom-6 sm:p-5">
<div className="flex gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[4px] border border-landing-border bg-landing-panel text-accent">
<ShieldCheck size={18} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<Dialog.Title className="text-base font-semibold leading-tight">
Cookie consent
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm leading-6 text-landing-muted">
Straude uses essential cookies for Supabase Auth sessions,
security, referrals, and this preference. Analytics stays off
unless you choose to accept all cookies.
</Dialog.Description>

<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="button"
size="md"
className="w-full border border-accent bg-accent text-accent-foreground sm:w-auto"
disabled={saving !== null}
onClick={() => savePreference("essential")}
>
{saving === "essential" ? "Saving..." : "Accept essential"}
</Button>
<Button
type="button"
variant="secondary"
size="md"
className="w-full border-landing-border bg-transparent text-landing-text hover:bg-landing-hover sm:w-auto"
disabled={saving !== null}
onClick={() => savePreference("all")}
>
{saving === "all" ? "Saving..." : "Accept all"}
</Button>
<Link
href="/privacy"
className="px-1 py-2 text-center text-xs font-semibold text-landing-muted underline underline-offset-4 hover:text-landing-text sm:ml-auto"
>
Privacy policy
</Link>
</div>
{error ? (
<p className="mt-3 text-xs font-medium text-error" role="alert">
{error}
</p>
) : null}
</div>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
10 changes: 10 additions & 0 deletions apps/web/components/providers/ConsentAwareAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import { Analytics } from "@vercel/analytics/next";
import { useAnalyticsConsent } from "@/components/providers/useAnalyticsConsent";

export function ConsentAwareAnalytics() {
const enabled = useAnalyticsConsent();

return enabled ? <Analytics /> : null;
}
Loading
Loading