From 334ed32d1e0bd01a63c455917e18bebb38b41816 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Tue, 2 Jun 2026 00:02:53 -0700 Subject: [PATCH 1/2] add cookie consent modal --- apps/web/__tests__/api/cookie-consent.test.ts | 54 ++++++++ .../web/__tests__/unit/cookie-consent.test.ts | 45 +++++++ .../join/[username]/opengraph-image.tsx | 1 - .../(landing)/join/[username]/ref-cookie.tsx | 17 ++- apps/web/app/(landing)/layout.tsx | 8 +- apps/web/app/(landing)/privacy/page.tsx | 14 ++- apps/web/app/api/cookie-consent/route.ts | 44 +++++++ apps/web/app/layout.tsx | 4 +- apps/web/app/opengraph-image.tsx | 1 - .../components/landing/CookieConsentModal.tsx | 115 ++++++++++++++++++ .../providers/ConsentAwareAnalytics.tsx | 10 ++ .../components/providers/PostHogProvider.tsx | 6 +- .../providers/useAnalyticsConsent.ts | 36 ++++++ apps/web/lib/cookie-consent.ts | 46 +++++++ apps/web/lib/open-stats.ts | 20 ++- apps/web/next.config.ts | 8 +- 16 files changed, 413 insertions(+), 16 deletions(-) create mode 100644 apps/web/__tests__/api/cookie-consent.test.ts create mode 100644 apps/web/__tests__/unit/cookie-consent.test.ts create mode 100644 apps/web/app/api/cookie-consent/route.ts create mode 100644 apps/web/components/landing/CookieConsentModal.tsx create mode 100644 apps/web/components/providers/ConsentAwareAnalytics.tsx create mode 100644 apps/web/components/providers/useAnalyticsConsent.ts create mode 100644 apps/web/lib/cookie-consent.ts diff --git a/apps/web/__tests__/api/cookie-consent.test.ts b/apps/web/__tests__/api/cookie-consent.test.ts new file mode 100644 index 00000000..530355ad --- /dev/null +++ b/apps/web/__tests__/api/cookie-consent.test.ts @@ -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(); + }); +}); diff --git a/apps/web/__tests__/unit/cookie-consent.test.ts b/apps/web/__tests__/unit/cookie-consent.test.ts new file mode 100644 index 00000000..51d7e8ae --- /dev/null +++ b/apps/web/__tests__/unit/cookie-consent.test.ts @@ -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(); + }); +}); diff --git a/apps/web/app/(landing)/join/[username]/opengraph-image.tsx b/apps/web/app/(landing)/join/[username]/opengraph-image.tsx index 00a3ed99..be4cecc4 100644 --- a/apps/web/app/(landing)/join/[username]/opengraph-image.tsx +++ b/apps/web/app/(landing)/join/[username]/opengraph-image.tsx @@ -141,7 +141,6 @@ export default async function Image({ flexDirection: "column", alignItems: "center", position: "relative", - zIndex: 1, }} > {/* Avatar */} diff --git a/apps/web/app/(landing)/join/[username]/ref-cookie.tsx b/apps/web/app/(landing)/join/[username]/ref-cookie.tsx index cde3005f..ed85e779 100644 --- a/apps/web/app/(landing)/join/[username]/ref-cookie.tsx +++ b/apps/web/app/(landing)/join/[username]/ref-cookie.tsx @@ -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; diff --git a/apps/web/app/(landing)/layout.tsx b/apps/web/app/(landing)/layout.tsx index 2106dd72..26983436 100644 --- a/apps/web/app/(landing)/layout.tsx +++ b/apps/web/app/(landing)/layout.tsx @@ -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" }, @@ -11,5 +12,10 @@ export default function LandingLayout({ }: { children: React.ReactNode; }) { - return <>{children}; + return ( + <> + {children} + + + ); } diff --git a/apps/web/app/(landing)/privacy/page.tsx b/apps/web/app/(landing)/privacy/page.tsx index 8b772e93..369e1a98 100644 --- a/apps/web/app/(landing)/privacy/page.tsx +++ b/apps/web/app/(landing)/privacy/page.tsx @@ -18,7 +18,7 @@ export default function PrivacyPage() { Privacy Policy

- Last updated: April 8, 2026 + Last updated: June 2, 2026

@@ -81,8 +81,8 @@ export default function PrivacyPage() { session count) — never prompts, code, or conversation content
  • - Analytics: page views and basic interaction - data via Vercel Analytics + Analytics, when you opt in: page views and + basic interaction data via Vercel Analytics and PostHog
  • @@ -135,6 +135,9 @@ export default function PrivacyPage() {
  • Vercel: application hosting and analytics
  • +
  • + PostHog: opt-in product analytics +
  • GitHub: OAuth authentication (if you sign in with GitHub) @@ -159,7 +162,10 @@ export default function PrivacyPage() {

    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.

    diff --git a/apps/web/app/api/cookie-consent/route.ts b/apps/web/app/api/cookie-consent/route.ts new file mode 100644 index 00000000..bf4c0c1b --- /dev/null +++ b/apps/web/app/api/cookie-consent/route.ts @@ -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([ + "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; +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 141ccd12..5fde7ced 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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"; @@ -131,7 +131,7 @@ export default function RootLayout({ {children} - + {process.env.NODE_ENV === "development" && } diff --git a/apps/web/app/opengraph-image.tsx b/apps/web/app/opengraph-image.tsx index 0874d321..665d1614 100644 --- a/apps/web/app/opengraph-image.tsx +++ b/apps/web/app/opengraph-image.tsx @@ -69,7 +69,6 @@ export default async function Image() { alignItems: "center", justifyContent: "center", position: "relative", - zIndex: 1, }} > {/* Logo: orange trapezoid */} diff --git a/apps/web/components/landing/CookieConsentModal.tsx b/apps/web/components/landing/CookieConsentModal.tsx new file mode 100644 index 00000000..5d266718 --- /dev/null +++ b/apps/web/components/landing/CookieConsentModal.tsx @@ -0,0 +1,115 @@ +"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(null); + const [error, setError] = useState(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 ( + + + + +
    +
    +
    +
    + + Cookie consent + + + Straude uses essential cookies for Supabase Auth sessions, + security, referrals, and this preference. Analytics stays off + unless you choose to accept all cookies. + + +
    + + + + Privacy policy + +
    + {error ? ( +

    + {error} +

    + ) : null} +
    +
    +
    +
    +
    + ); +} diff --git a/apps/web/components/providers/ConsentAwareAnalytics.tsx b/apps/web/components/providers/ConsentAwareAnalytics.tsx new file mode 100644 index 00000000..34799883 --- /dev/null +++ b/apps/web/components/providers/ConsentAwareAnalytics.tsx @@ -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 ? : null; +} diff --git a/apps/web/components/providers/PostHogProvider.tsx b/apps/web/components/providers/PostHogProvider.tsx index 117f3950..a0796c51 100644 --- a/apps/web/components/providers/PostHogProvider.tsx +++ b/apps/web/components/providers/PostHogProvider.tsx @@ -6,6 +6,7 @@ import posthog from "posthog-js"; import { PostHogProvider as PHProvider } from "posthog-js/react"; import type { User } from "@supabase/supabase-js"; import { createClient } from "@/lib/supabase/client"; +import { useAnalyticsConsent } from "@/components/providers/useAnalyticsConsent"; const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY; @@ -28,9 +29,10 @@ export function PostHogClientProvider({ }) { const initialized = useRef(false); const [ready, setReady] = useState(false); + const analyticsConsent = useAnalyticsConsent(); useEffect(() => { - if (!POSTHOG_KEY || initialized.current) return; + if (!analyticsConsent || !POSTHOG_KEY || initialized.current) return; let readyTimer: number | null = null; posthog.init(POSTHOG_KEY, { api_host: "/ingest", @@ -62,7 +64,7 @@ export function PostHogClientProvider({ if (readyTimer !== null) window.clearTimeout(readyTimer); subscription.unsubscribe(); }; - }, []); + }, [analyticsConsent]); return ( diff --git a/apps/web/components/providers/useAnalyticsConsent.ts b/apps/web/components/providers/useAnalyticsConsent.ts new file mode 100644 index 00000000..7b292dab --- /dev/null +++ b/apps/web/components/providers/useAnalyticsConsent.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { + COOKIE_CONSENT_EVENT, + type CookieConsentEventDetail, + getCookieConsentFromCookieString, +} from "@/lib/cookie-consent"; + +let analyticsConsentOverride: boolean | null = null; + +function subscribe(callback: () => void) { + const handleConsent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail?.analytics === "boolean") { + analyticsConsentOverride = detail.analytics; + } + callback(); + }; + + window.addEventListener(COOKIE_CONSENT_EVENT, handleConsent); + return () => window.removeEventListener(COOKIE_CONSENT_EVENT, handleConsent); +} + +function getSnapshot() { + if (analyticsConsentOverride !== null) return analyticsConsentOverride; + return getCookieConsentFromCookieString(document.cookie)?.analytics ?? false; +} + +function getServerSnapshot() { + return false; +} + +export function useAnalyticsConsent() { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/apps/web/lib/cookie-consent.ts b/apps/web/lib/cookie-consent.ts new file mode 100644 index 00000000..e1db3bf0 --- /dev/null +++ b/apps/web/lib/cookie-consent.ts @@ -0,0 +1,46 @@ +export const COOKIE_CONSENT_COOKIE = "straude_cookie_consent"; +export const COOKIE_CONSENT_EVENT = "straude:cookie-consent"; +export const COOKIE_CONSENT_MAX_AGE = 60 * 60 * 24 * 365; + +const COOKIE_CONSENT_VERSION = "v1"; + +export type CookieConsentPreference = "essential" | "all"; + +export type CookieConsentState = { + preference: CookieConsentPreference; + analytics: boolean; +}; + +export type CookieConsentEventDetail = CookieConsentState; + +export function serializeCookieConsent(preference: CookieConsentPreference) { + return `${COOKIE_CONSENT_VERSION}-${preference}`; +} + +export function parseCookieConsent( + value: string | null | undefined, +): CookieConsentState | null { + if (!value) return null; + + const decoded = decodeURIComponent(value); + if (decoded === serializeCookieConsent("essential")) { + return { preference: "essential", analytics: false }; + } + + if (decoded === serializeCookieConsent("all")) { + return { preference: "all", analytics: true }; + } + + return null; +} + +export function getCookieConsentFromCookieString(cookieString: string) { + const target = `${COOKIE_CONSENT_COOKIE}=`; + const entry = cookieString + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(target)); + + if (!entry) return null; + return parseCookieConsent(entry.slice(target.length)); +} diff --git a/apps/web/lib/open-stats.ts b/apps/web/lib/open-stats.ts index f488febc..c6c80171 100644 --- a/apps/web/lib/open-stats.ts +++ b/apps/web/lib/open-stats.ts @@ -180,6 +180,20 @@ function throwIfSupabaseError(label: string, error: SupabaseErrorLike) { throw new Error(`${label}: ${details}`); } +function isLocalSupabaseConnectionError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return ( + (message.includes("127.0.0.1:54321") || + message.includes("localhost:54321")) && + (message.includes("ECONNREFUSED") || message.includes("fetch failed")) + ); +} + +function logOpenStatsError(message: string, error: unknown) { + if (isLocalSupabaseConnectionError(error)) return; + console.error(message, error); +} + function buildOpenStats(params: { usageRows: UsageRow[]; concentrationRows: unknown; @@ -409,7 +423,7 @@ export async function getOpenStatsForPage( // TODO(observability): forward to PostHog server-side capture once a // helper exists (context: "open-stats:snapshot-write"). For now, log // so prod failures are visible in server logs. - console.error("open stats snapshot write failed:", error); + logOpenStatsError("open stats snapshot write failed:", error); } return liveStats; @@ -420,14 +434,14 @@ export async function getOpenStatsForPage( } catch (snapshotError) { // TODO(observability): forward to PostHog server-side capture once a // helper exists (context: "open-stats:snapshot-fallback"). - console.error("open stats snapshot fallback failed:", snapshotError); + logOpenStatsError("open stats snapshot fallback failed:", snapshotError); } // Both live and snapshot failed (e.g. Supabase unreachable in CI). // Return an empty placeholder so the build doesn't crash. // TODO(observability): forward to PostHog server-side capture once a // helper exists (context: "open-stats:all-sources-failed"). - console.error("open stats: all sources failed, returning placeholder", liveError); + logOpenStatsError("open stats: all sources failed, returning placeholder", liveError); const now = new Date().toISOString(); return { trackedUsers: 0, diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 26fa5f68..62657cf6 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,5 +1,11 @@ import type { NextConfig } from "next"; +const scriptSrc = [ + "script-src 'self' 'unsafe-inline'", + process.env.NODE_ENV === "development" ? "'unsafe-eval'" : null, + "https://va.vercel-scripts.com", +].filter(Boolean).join(" "); + const nextConfig: NextConfig = { poweredByHeader: false, skipTrailingSlashRedirect: true, @@ -56,7 +62,7 @@ const nextConfig: NextConfig = { key: "Content-Security-Policy", value: [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' https://va.vercel-scripts.com", + scriptSrc, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https://avatars.githubusercontent.com https://unavatar.io https://*.supabase.co https://api.producthunt.com http://127.0.0.1:54321 http://localhost:54321", "font-src 'self' data:", From d4d5cdbace08431175ed224666c779705b16301a Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Tue, 2 Jun 2026 00:32:30 -0700 Subject: [PATCH 2/2] fix cookie consent navigation overlay --- apps/web/components/landing/CookieConsentModal.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/components/landing/CookieConsentModal.tsx b/apps/web/components/landing/CookieConsentModal.tsx index 5d266718..19d2daad 100644 --- a/apps/web/components/landing/CookieConsentModal.tsx +++ b/apps/web/components/landing/CookieConsentModal.tsx @@ -56,9 +56,8 @@ export function CookieConsentModal() { if (!checked || !open) return null; return ( - + -