diff --git a/.claude/settings.json b/.claude/settings.json index 48be3f5d..b2f3e1c7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,8 +1,6 @@ { "permissions": { - "allow": [ - "*" - ] + "allow": [] }, "hooks": { "PostToolUse": [ diff --git a/apps/web/__tests__/api/activation-analytics.test.ts b/apps/web/__tests__/api/activation-analytics.test.ts new file mode 100644 index 00000000..63442712 --- /dev/null +++ b/apps/web/__tests__/api/activation-analytics.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(), +})); + +vi.mock("@/lib/analytics/server", () => ({ + captureServerActivationEvent: vi.fn().mockResolvedValue(true), + identifyServerActivationUser: vi.fn().mockResolvedValue(true), +})); + +import { POST } from "@/app/api/analytics/activation/route"; +import { ACTIVATION_ANONYMOUS_COOKIE } from "@/lib/analytics/activation"; +import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server"; +import { createClient } from "@/lib/supabase/server"; + +function mockAuthUser(userId: string | null) { + vi.mocked(createClient).mockResolvedValue({ + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: userId ? { id: userId } : null }, + }), + }, + } as any); +} + +function request(body: unknown, cookie?: string) { + return new Request("http://localhost/api/analytics/activation", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/analytics/activation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAuthUser(null); + }); + + it("captures anonymous lifecycle events with a short-lived activation id cookie", async () => { + const res = await POST(request({ + event: "signup_started", + properties: { + surface: "signup", + signup_method: "magic_link", + email: "private@example.com", + }, + })); + + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toContain(`${ACTIVATION_ANONYMOUS_COOKIE}=`); + expect(captureServerActivationEvent).toHaveBeenCalledWith(expect.objectContaining({ + event: "signup_started", + distinctId: expect.any(String), + properties: expect.objectContaining({ + surface: "signup", + signup_method: "magic_link", + is_authenticated: false, + activation_state: "anonymous", + }), + })); + expect(captureServerActivationEvent).not.toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ email: expect.anything() }), + })); + }); + + it("identifies authenticated users against the anonymous activation id", async () => { + mockAuthUser("user-1"); + + const res = await POST(request( + { + event: "sync_command_copied", + properties: { + surface: "onboarding", + command: "npx straude@latest", + }, + }, + `${ACTIVATION_ANONYMOUS_COOKIE}=anon-1`, + )); + + expect(res.status).toBe(200); + expect(identifyServerActivationUser).toHaveBeenCalledWith({ + distinctId: "user-1", + anonymousDistinctId: "anon-1", + properties: { + is_authenticated: true, + activation_state: "sync_command_copied", + }, + }); + expect(captureServerActivationEvent).toHaveBeenCalledWith(expect.objectContaining({ + event: "sync_command_copied", + distinctId: "user-1", + properties: expect.objectContaining({ + is_authenticated: true, + activation_state: "sync_command_copied", + }), + })); + }); + + it("rejects events that are not in the client lifecycle allowlist", async () => { + const res = await POST(request({ event: "usage_submit_succeeded" })); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.error).toBe("Invalid activation event"); + expect(captureServerActivationEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/api/profile.test.ts b/apps/web/__tests__/api/profile.test.ts index 2265af6e..c4a3f6f5 100644 --- a/apps/web/__tests__/api/profile.test.ts +++ b/apps/web/__tests__/api/profile.test.ts @@ -8,6 +8,18 @@ vi.mock("@/lib/supabase/service", () => ({ getServiceClient: vi.fn(), })); +vi.mock("@/lib/email/send-welcome-email", () => ({ + sendWelcomeEmail: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/referral", () => ({ + attributeReferral: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/analytics/server", () => ({ + captureServerActivationEvent: vi.fn().mockResolvedValue(true), +})); + vi.mock("@/lib/constants/regions", () => ({ COUNTRY_TO_REGION: { US: "north_america", @@ -18,6 +30,8 @@ vi.mock("@/lib/constants/regions", () => ({ import { GET as getPublicProfile } from "@/app/api/users/[username]/route"; import { GET as getOwnProfile, PATCH } from "@/app/api/users/me/route"; +import { captureServerActivationEvent } from "@/lib/analytics/server"; +import { sendWelcomeEmail } from "@/lib/email/send-welcome-email"; import { createClient } from "@/lib/supabase/server"; import { getServiceClient } from "@/lib/supabase/service"; import { NextRequest } from "next/server"; @@ -351,6 +365,131 @@ describe("PATCH /api/users/me", () => { expect(json.username).toBe("new_name"); }); + it("does not complete onboarding before first sync is present", async () => { + const authClient: Record = { + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: { id: "u-1", email: "u1@example.com" } }, + error: null, + }), + }, + }; + const updateMock = vi.fn(); + const dailyUsageChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: null, + error: null, + }), + }; + const db = { + from: vi.fn((table: string) => { + if (table === "daily_usage") return dailyUsageChain; + if (table === "users") return { update: updateMock }; + throw new Error(`Unexpected table ${table}`); + }), + }; + (createClient as any).mockResolvedValue(authClient); + (getServiceClient as any).mockReturnValue(db); + + const res = await PATCH( + makeRequest("PATCH", "/api/users/me", { onboarding_completed: true }) + ); + const json = await res.json(); + + expect(res.status).toBe(409); + expect(json.error).toBe("Sync your first session before completing onboarding"); + expect(updateMock).not.toHaveBeenCalled(); + expect(sendWelcomeEmail).not.toHaveBeenCalled(); + expect(captureServerActivationEvent).not.toHaveBeenCalled(); + }); + + it("completes onboarding after first sync and captures activation", async () => { + const authClient: Record = { + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: { id: "u-1", email: "u1@example.com" } }, + error: null, + }), + }, + }; + const usageRow = { + id: "usage-1", + session_count: 2, + total_tokens: 2500, + }; + const updatedProfile = { + id: "u-1", + username: "alice", + onboarding_completed: true, + }; + const dailyUsageChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: usageRow, + error: null, + }), + }; + const updateMock = vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: updatedProfile, + error: null, + }), + }), + }), + }); + const leaderboardChain = { + select: vi.fn().mockReturnThis(), + neq: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue({ data: [], error: null }), + }; + const db = { + from: vi.fn((table: string) => { + if (table === "daily_usage") return dailyUsageChain; + if (table === "users") return { update: updateMock }; + if (table === "leaderboard_weekly") return leaderboardChain; + throw new Error(`Unexpected table ${table}`); + }), + }; + (createClient as any).mockResolvedValue(authClient); + (getServiceClient as any).mockReturnValue(db); + + const res = await PATCH( + makeRequest("PATCH", "/api/users/me", { onboarding_completed: true }) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.onboarding_completed).toBe(true); + expect(updateMock).toHaveBeenCalledWith({ onboarding_completed: true }); + expect(sendWelcomeEmail).toHaveBeenCalledWith({ + userId: "u-1", + email: "u1@example.com", + username: "alice", + }); + expect(captureServerActivationEvent).toHaveBeenCalledWith({ + event: "activation_completed", + distinctId: "u-1", + properties: expect.objectContaining({ + surface: "onboarding", + activation_state: "activated", + is_authenticated: true, + session_count: 2, + total_tokens: 2500, + "$insert_id": "activation_completed:usage-1", + }), + }); + }); + it("validates username format", async () => { const client: Record = { auth: { diff --git a/apps/web/__tests__/api/usage-status.test.ts b/apps/web/__tests__/api/usage-status.test.ts new file mode 100644 index 00000000..b72c94ed --- /dev/null +++ b/apps/web/__tests__/api/usage-status.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(), +})); + +vi.mock("@/lib/analytics/server", () => ({ + captureServerActivationEvent: vi.fn().mockResolvedValue(true), +})); + +import { GET } from "@/app/api/usage/status/route"; +import { captureServerActivationEvent } from "@/lib/analytics/server"; +import { createClient } from "@/lib/supabase/server"; + +function mockUsageStatus({ + latestUsage, + totals = { total_cost: 0, total_tokens: 0 }, + latestPost = null, + latestUsageError = null, +}: { + latestUsage: unknown; + totals?: unknown; + latestPost?: unknown; + latestUsageError?: unknown; +}) { + const latestUsageChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: latestUsage, + error: latestUsageError, + }), + }; + const latestPostChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: latestPost, + error: null, + }), + }; + const rpcChain = { + single: vi.fn().mockResolvedValue({ + data: totals, + error: null, + }), + }; + vi.mocked(createClient).mockResolvedValue({ + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: { id: "user-1" } }, + }), + }, + from: vi.fn((table: string) => { + if (table === "daily_usage") return latestUsageChain; + if (table === "posts") return latestPostChain; + throw new Error(`Unexpected table ${table}`); + }), + rpc: vi.fn(() => rpcChain), + } as any); + + return { latestUsageChain, latestPostChain, rpcChain }; +} + +describe("GET /api/usage/status", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not activate users without usage", async () => { + const { latestUsageChain } = mockUsageStatus({ latestUsage: null }); + + const res = await GET(); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json).toEqual({ has_data: false, has_usage: false }); + expect(latestUsageChain.limit).toHaveBeenCalledWith(1); + expect(captureServerActivationEvent).not.toHaveBeenCalled(); + }); + + it("captures first sync confirmation when web observes usage", async () => { + mockUsageStatus({ + latestUsage: { + id: "usage-1", + date: "2026-07-02", + created_at: "2026-07-02T10:00:00.000Z", + cost_usd: 1.25, + total_tokens: 2500, + output_tokens: 1200, + session_count: 2, + models: ["claude-sonnet-4-5-20250929"], + }, + totals: { + total_cost: 7.75, + total_tokens: 9000, + }, + latestPost: { id: "post-1" }, + }); + + const res = await GET(); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.has_data).toBe(true); + expect(json.has_usage).toBe(true); + expect(json.cost_usd).toBe(7.75); + expect(json.total_tokens).toBe(9000); + expect(json.session_count).toBe(2); + expect(json.latest_usage_id).toBe("usage-1"); + expect(json.latest_usage_at).toBe("2026-07-02T10:00:00.000Z"); + expect(json.latest_post_url).toBe("/post/post-1"); + expect(captureServerActivationEvent).toHaveBeenCalledWith({ + event: "first_sync_confirmed", + distinctId: "user-1", + properties: expect.objectContaining({ + surface: "usage_status", + activation_state: "activated", + is_authenticated: true, + session_count: 2, + total_tokens: 9000, + total_cost_usd: 7.75, + "$insert_id": "first_sync_confirmed:user-1:usage-1", + }), + }); + }); +}); diff --git a/apps/web/__tests__/api/usage-submit.test.ts b/apps/web/__tests__/api/usage-submit.test.ts index 2cf9e184..1d529462 100644 --- a/apps/web/__tests__/api/usage-submit.test.ts +++ b/apps/web/__tests__/api/usage-submit.test.ts @@ -13,11 +13,16 @@ vi.mock("@/lib/supabase/service", () => ({ getServiceClient: vi.fn(), })); +vi.mock("@/lib/analytics/server", () => ({ + captureServerActivationEvent: vi.fn().mockResolvedValue(true), +})); + vi.mock("@supabase/supabase-js", () => ({ createClient: vi.fn(), })); import { POST, aggregateDeviceRows } from "@/app/api/usage/submit/route"; +import { captureServerActivationEvent } from "@/lib/analytics/server"; import { createClient } from "@/lib/supabase/server"; import { verifyCliToken, verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; import { getServiceClient } from "@/lib/supabase/service"; @@ -214,6 +219,18 @@ describe("POST /api/usage/submit", () => { expect(json.results[0].post_id).toBe("post-1"); expect(json.results[0].post_url).toBe("https://straude.com/post/post-1"); expect(svc.rpc).toHaveBeenCalledWith("recalculate_user_level", { p_user_id: "user-1" }); + expect(captureServerActivationEvent).toHaveBeenCalledWith(expect.objectContaining({ + event: "usage_submit_succeeded", + distinctId: "user-1", + properties: expect.objectContaining({ + surface: "usage_submit", + activation_state: "first_usage_submitted", + is_authenticated: true, + days_pushed: 1, + result_count: 1, + total_tokens: 1500, + }), + })); }); it("submits multiple days (batch)", async () => { @@ -383,7 +400,7 @@ describe("POST /api/usage/submit", () => { expect(json.error).toContain("Unsupported ccusage agents"); }); - it("rejects non-online ccusage pricing metadata", async () => { + it("rejects unsupported ccusage pricing metadata", async () => { (verifyCliToken as any).mockReturnValue("user-1"); mockServiceClient(); @@ -1582,7 +1599,7 @@ describe("POST /api/usage/submit", () => { claude: "ccusage-claude-v20", ccusage_version: "20.0.6", ccusage_agents: ["claude"], - pricing_mode: "online", + pricing_mode: "offline", }, }) ); @@ -1591,7 +1608,7 @@ describe("POST /api/usage/submit", () => { claude: "ccusage-claude-v20", ccusage_version: "20.0.6", ccusage_agents: ["claude"], - pricing_mode: "online", + pricing_mode: "offline", }; expect(res.status).toBe(200); expect(svc.upsert.mock.calls[0][0].collector_meta).toEqual(expectedMeta); diff --git a/apps/web/__tests__/components/CommandPalette.test.tsx b/apps/web/__tests__/components/CommandPalette.test.tsx index 0f0e3620..9eeed6f9 100644 --- a/apps/web/__tests__/components/CommandPalette.test.tsx +++ b/apps/web/__tests__/components/CommandPalette.test.tsx @@ -1,4 +1,4 @@ -import { act, render, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ThemeProvider } from "@/components/providers/ThemeProvider"; @@ -40,6 +40,7 @@ vi.mock("kbar", () => ({ }) =>
{children}
, KBarSearch: ({ className }: { className?: string }) => , KBarResults: () => null, + useKBar: () => ({ query: { toggle: vi.fn() } }), useMatches: () => ({ results: [] }), })); @@ -92,9 +93,13 @@ describe("CommandPalette", () => { , ); - expect(capturedActions.map((action) => action.id)).toEqual( - expect.arrayContaining(["theme-light", "theme-dark", "theme-system"]), - ); + fireEvent.keyDown(window, { key: "k", metaKey: true }); + + await waitFor(() => { + expect(capturedActions.map((action) => action.id)).toEqual( + expect.arrayContaining(["theme-light", "theme-dark", "theme-system"]), + ); + }); const darkAction = capturedActions.find((action) => action.id === "theme-dark"); act(() => { diff --git a/apps/web/__tests__/components/FeedList.test.tsx b/apps/web/__tests__/components/FeedList.test.tsx index 0cca75cb..7cd7b6c3 100644 --- a/apps/web/__tests__/components/FeedList.test.tsx +++ b/apps/web/__tests__/components/FeedList.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FeedList } from "@/components/app/feed/FeedList"; import type { Post } from "@/types"; @@ -15,6 +15,12 @@ vi.mock("@/components/app/feed/PendingPostsNudge", () => ({ ), })); +vi.mock("@/lib/analytics/client", () => ({ + trackActivationEvent: vi.fn(), +})); + +import { trackActivationEvent } from "@/lib/analytics/client"; + let intersectionCallback: | ((entries: Array<{ isIntersecting: boolean }>) => void) | null = null; @@ -68,6 +74,12 @@ function makePost(id: string, overrides: Partial = {}): Post { describe("FeedList", () => { beforeEach(() => { intersectionCallback = null; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); vi.stubGlobal( "fetch", @@ -157,4 +169,53 @@ describe("FeedList", () => { ); expect(request.searchParams.get("limit")).toBe("20"); }); + + it("shows a copyable first-sync command for the signed-in empty sessions feed", async () => { + render( + , + ); + + const emptyState = screen.getByRole("region", { name: /sync your first session/i }); + expect(within(emptyState).getByText("Sync your first session")).toBeInTheDocument(); + expect(within(emptyState).getByText("npx straude@latest")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /copy first sync command/i })); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("npx straude@latest"); + }); + expect(trackActivationEvent).toHaveBeenCalledWith("sync_command_copied", expect.objectContaining({ + surface: "empty_state", + cta_location: "feed_empty_state", + command: "npx straude@latest", + })); + }); + + it("shows a contextual signup CTA after guest feed content", () => { + render( + , + ); + + const cta = screen.getByRole("link", { name: /start your streak/i }); + expect(cta).toHaveAttribute("href", "/signup"); + + cta.addEventListener("click", (event) => event.preventDefault()); + fireEvent.click(cta); + + expect(trackActivationEvent).toHaveBeenCalledWith("guest_signup_cta_clicked", expect.objectContaining({ + surface: "feed", + cta_location: "feed_after_posts", + destination: "/signup", + })); + }); }); diff --git a/apps/web/__tests__/components/SubmitPromptWidget.test.tsx b/apps/web/__tests__/components/SubmitPromptWidget.test.tsx index ceaf4649..6b4b1bc8 100644 --- a/apps/web/__tests__/components/SubmitPromptWidget.test.tsx +++ b/apps/web/__tests__/components/SubmitPromptWidget.test.tsx @@ -7,6 +7,11 @@ describe("SubmitPromptWidget", () => { vi.restoreAllMocks(); }); + async function openPromptModal() { + fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i })); + return screen.findByRole("dialog"); + } + it("opens the modal and submits a prompt", async () => { const fetchMock = vi.spyOn(global, "fetch" as any).mockResolvedValue({ ok: true, @@ -15,8 +20,7 @@ describe("SubmitPromptWidget", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i })); - expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(await openPromptModal()).toBeInTheDocument(); fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Please add a compact mode for activity cards in the feed." }, @@ -52,7 +56,7 @@ describe("SubmitPromptWidget", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i })); + await openPromptModal(); fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Please add markdown shortcuts in comments." }, @@ -74,7 +78,7 @@ describe("SubmitPromptWidget", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i })); + await openPromptModal(); fireEvent.click(screen.getByRole("button", { name: /submit as anonymous/i })); fireEvent.change(screen.getByLabelText("Prompt"), { @@ -96,10 +100,10 @@ describe("SubmitPromptWidget", () => { }); }); - it("shows the submit keyboard shortcut hint", () => { + it("shows the submit keyboard shortcut hint", async () => { render(); - fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i })); + await openPromptModal(); expect( screen.getByRole("button", { name: /submit prompt ⌘↵/i }), @@ -131,7 +135,7 @@ describe("SubmitPromptWidget", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i })); + await openPromptModal(); fireEvent.click(screen.getByRole("button", { name: /view community prompts/i })); await waitFor(() => { diff --git a/apps/web/__tests__/components/TopHeader.test.tsx b/apps/web/__tests__/components/TopHeader.test.tsx index 117f63e3..179a8d18 100644 --- a/apps/web/__tests__/components/TopHeader.test.tsx +++ b/apps/web/__tests__/components/TopHeader.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ComponentProps } from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TopHeader } from "@/components/app/shared/TopHeader"; @@ -82,7 +82,7 @@ describe("TopHeader", () => { }); }); - it("loads shared app counts without fetching message threads", async () => { + it("loads shared app counts without fetching notification lists or message threads", async () => { renderTopHeader({ username: "alice", avatarUrl: null }); await waitFor(() => { @@ -93,7 +93,21 @@ describe("TopHeader", () => { String(input), ); - expect(requestedUrls).toContain("/api/notifications"); + expect(requestedUrls).not.toContain("/api/notifications"); expect(requestedUrls).not.toContain("/api/messages/threads?limit=1"); }); + + it("fetches notifications when the notifications menu opens", async () => { + renderTopHeader({ username: "alice", avatarUrl: null }); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith("/api/app/counts"); + }); + + fireEvent.click(screen.getByRole("button", { name: /notifications/i })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith("/api/notifications"); + }); + }); }); diff --git a/apps/web/__tests__/flows/activation-contract.test.ts b/apps/web/__tests__/flows/activation-contract.test.ts new file mode 100644 index 00000000..887bba8a --- /dev/null +++ b/apps/web/__tests__/flows/activation-contract.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + ACTIVATION_EVENTS, + deriveActivationState, + sanitizeActivationProperties, +} from "@/lib/analytics/activation"; + +describe("activation funnel contract", () => { + it("keeps the canonical event names stable", () => { + expect(ACTIVATION_EVENTS).toEqual([ + "landing_primary_cta_clicked", + "guest_signup_cta_clicked", + "signup_started", + "signup_completed", + "onboarding_profile_started", + "sync_command_copied", + "first_sync_nudge_clicked", + "usage_submit_succeeded", + "first_sync_confirmed", + "activation_completed", + ]); + }); + + it("treats unauthenticated visitors as anonymous", () => { + expect(deriveActivationState({ isAuthenticated: false })).toBe("anonymous"); + }); + + it("does not treat signup or profile setup as activation", () => { + expect(deriveActivationState({ isAuthenticated: true })).toBe("signed_up"); + expect(deriveActivationState({ + isAuthenticated: true, + profileStarted: true, + })).toBe("profile_started"); + }); + + it("does not treat command copy or usage submit as activation", () => { + expect(deriveActivationState({ + isAuthenticated: true, + syncCommandCopied: true, + })).toBe("sync_command_copied"); + expect(deriveActivationState({ + isAuthenticated: true, + usageSubmitted: true, + })).toBe("first_usage_submitted"); + }); + + it("defines activated as first sync confirmed in web", () => { + expect(deriveActivationState({ + isAuthenticated: true, + profileStarted: true, + syncCommandCopied: true, + usageSubmitted: true, + webSyncConfirmed: true, + })).toBe("activated"); + }); + + it("strips private and high-cardinality fields from activation properties", () => { + expect(sanitizeActivationProperties({ + surface: "onboarding", + activation_state: "activated", + is_authenticated: true, + total_tokens: 1234, + ccusage_agents: ["claude", "codex"], + email: "user@example.com", + prompt: "secret prompt", + path: "/Users/someone/project", + raw_usage_rows: [{ date: "2026-01-01" }], + })).toEqual({ + surface: "onboarding", + activation_state: "activated", + is_authenticated: true, + total_tokens: 1234, + ccusage_agents: ["claude", "codex"], + }); + }); +}); diff --git a/apps/web/__tests__/lib/analytics-server.test.ts b/apps/web/__tests__/lib/analytics-server.test.ts new file mode 100644 index 00000000..b22714a4 --- /dev/null +++ b/apps/web/__tests__/lib/analytics-server.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureServerActivationEvent } from "@/lib/analytics/server"; + +describe("server PostHog activation capture", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("posts sanitized activation events to PostHog capture", async () => { + vi.stubEnv("NEXT_PUBLIC_POSTHOG_KEY", "phc_test"); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const result = await captureServerActivationEvent({ + event: "activation_completed", + distinctId: "user-1", + properties: { + surface: "onboarding", + activation_state: "activated", + is_authenticated: true, + total_tokens: 5000, + prompt: "do not send", + } as any, + }); + + expect(result).toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + "https://us.i.posthog.com/capture/", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body).toMatchObject({ + api_key: "phc_test", + event: "activation_completed", + distinct_id: "user-1", + properties: { + surface: "onboarding", + activation_state: "activated", + is_authenticated: true, + total_tokens: 5000, + }, + }); + expect(body.properties.prompt).toBeUndefined(); + }); + + it("skips capture when no PostHog project key is configured", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(captureServerActivationEvent({ + event: "signup_completed", + distinctId: "user-1", + })).resolves.toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/lib/open-stats.test.ts b/apps/web/__tests__/lib/open-stats.test.ts index 0cb74040..35d66555 100644 --- a/apps/web/__tests__/lib/open-stats.test.ts +++ b/apps/web/__tests__/lib/open-stats.test.ts @@ -3,6 +3,7 @@ import { type OpenStats, type OpenStatsDb, getOpenStatsForPage, + refreshOpenStatsSnapshot, } from "@/lib/open-stats"; type FixtureOptions = { @@ -116,6 +117,61 @@ afterEach(() => { }); describe("getOpenStatsForPage", () => { + it("returns the latest snapshot without running live aggregation", async () => { + const snapshotStats = makeSnapshotStats({ + totalSpend: 101_001, + snapshotDate: "2026-03-31", + fetchedAt: "2026-03-31T23:59:59.000Z", + }); + const { db, snapshotUpsert } = makeDb({ + snapshotRows: [ + { + snapshot_date: "2026-03-31", + captured_at: "2026-03-31T23:59:59.000Z", + stats: snapshotStats, + }, + ], + }); + + const stats = await getOpenStatsForPage(db); + + expect(stats.source).toBe("snapshot"); + expect(stats.totalSpend).toBe(101_001); + expect(stats.snapshotDate).toBe("2026-03-31"); + expect(db.from).not.toHaveBeenCalledWith("daily_usage"); + expect(db.rpc).not.toHaveBeenCalled(); + expect(snapshotUpsert).not.toHaveBeenCalled(); + }); + + it("returns placeholder stats when the snapshot read fails", async () => { + const { db } = makeDb({ + snapshotError: { message: "connection refused" }, + }); + + const stats = await getOpenStatsForPage(db); + + expect(stats.source).toBe("snapshot"); + expect(stats.totalSpend).toBe(0); + expect(stats.trackedUsers).toBe(0); + expect(stats.models).toEqual([]); + expect(db.from).not.toHaveBeenCalledWith("daily_usage"); + expect(db.rpc).not.toHaveBeenCalled(); + }); + + it("returns placeholder stats when no snapshot exists", async () => { + const { db } = makeDb({ snapshotRows: [] }); + + const stats = await getOpenStatsForPage(db); + + expect(stats.source).toBe("snapshot"); + expect(stats.totalSpend).toBe(0); + expect(stats.trackedUsers).toBe(0); + expect(db.from).not.toHaveBeenCalledWith("daily_usage"); + expect(db.rpc).not.toHaveBeenCalled(); + }); +}); + +describe("refreshOpenStatsSnapshot", () => { it("returns live stats and writes the latest snapshot", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z")); @@ -143,7 +199,7 @@ describe("getOpenStatsForPage", () => { spendRows: [{ date: "2026-04-01", daily_total: 12.5, cumulative_total: 12.5 }], }); - const stats = await getOpenStatsForPage(db); + const stats = await refreshOpenStatsSnapshot(db); expect(stats.source).toBe("live"); expect(stats.totalSpend).toBe(12.5); @@ -159,64 +215,25 @@ describe("getOpenStatsForPage", () => { ); }); - it("falls back to the latest snapshot when the live query fails", async () => { - const snapshotStats = makeSnapshotStats(); - const { db, snapshotUpsert } = makeDb({ - usageError: { message: "database offline" }, - snapshotRows: [ - { - snapshot_date: "2026-04-01", - captured_at: "2026-04-01T12:00:00.000Z", - stats: snapshotStats, - }, - ], - }); - - const stats = await getOpenStatsForPage(db); - - expect(stats.source).toBe("snapshot"); - expect(stats.totalSpend).toBe(snapshotStats.totalSpend); - expect(stats.fetchedAt).toBe(snapshotStats.fetchedAt); - expect(snapshotUpsert).not.toHaveBeenCalled(); - }); - - it("returns placeholder stats when both live and snapshot fail", async () => { + it("throws when the live query fails", async () => { const { db } = makeDb({ - usageError: { message: "connection refused" }, - snapshotError: { message: "connection refused" }, + usageError: { message: "database offline" }, }); - const stats = await getOpenStatsForPage(db); - - expect(stats.source).toBe("snapshot"); - expect(stats.totalSpend).toBe(0); - expect(stats.trackedUsers).toBe(0); - expect(stats.models).toEqual([]); + await expect(refreshOpenStatsSnapshot(db)).rejects.toThrow( + "open stats daily_usage query failed", + ); }); - it("falls back to the latest snapshot when live stats come back empty", async () => { - const snapshotStats = makeSnapshotStats({ - totalSpend: 101_001, - snapshotDate: "2026-03-31", - fetchedAt: "2026-03-31T23:59:59.000Z", - }); + it("throws when live stats come back empty", async () => { const { db } = makeDb({ usageRows: [], concentrationRows: [], growthRows: [], - snapshotRows: [ - { - snapshot_date: "2026-03-31", - captured_at: "2026-03-31T23:59:59.000Z", - stats: snapshotStats, - }, - ], }); - const stats = await getOpenStatsForPage(db); - - expect(stats.source).toBe("snapshot"); - expect(stats.totalSpend).toBe(101_001); - expect(stats.snapshotDate).toBe("2026-03-31"); + await expect(refreshOpenStatsSnapshot(db)).rejects.toThrow( + "open stats daily_usage query returned no rows", + ); }); }); diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index a60b358a..9c16b852 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -4,14 +4,16 @@ import { createClient } from "@/lib/supabase/server"; import { getServiceClient } from "@/lib/supabase/service"; import { getAuthUser } from "@/lib/supabase/auth"; import { Sidebar } from "@/components/app/shared/Sidebar"; -import { RightSidebar } from "@/components/app/shared/RightSidebar"; +import { LazyRightSidebar } from "@/components/app/shared/RightSidebar"; import { InviteButton } from "@/components/app/profile/InviteButton"; import { ResponsiveShellFrame } from "@/components/app/shared/ResponsiveShellFrame"; import { GuestHeader, GuestMobileNav } from "@/components/app/shared/GuestHeader"; import { CommandPalette } from "@/components/app/shared/CommandPalette"; import { Avatar } from "@/components/ui/Avatar"; import { Skeleton } from "@/components/ui/Skeleton"; +import { AppProviders } from "@/components/providers/AppProviders"; import { firstRelation } from "@/lib/utils/first-relation"; +import { loadUsageTotals } from "@/lib/data/usage-totals"; import type { DailyUsage } from "@/types"; type ShellProfile = { @@ -31,66 +33,8 @@ type LatestPostRow = { daily_usage: Array> | null; }; -type UsageFallbackRow = Pick; -type UsageTotalsRpcRow = { - total_cost: number | string | null; - total_tokens: number | string | null; -}; type SupabaseServerClient = Awaited>; -async function loadUsageTotals( - supabase: SupabaseServerClient, - userId: string, -): Promise<{ totalTokens: number; totalCost: number }> { - const usageTotalsRes = await supabase - .rpc("get_user_usage_totals", { p_user_id: userId }) - .single(); - - const loadFallbackUsageTotals = async (): Promise<{ totalTokens: number; totalCost: number }> => { - const { data: fallbackRows, error: fallbackError } = await supabase - .from("daily_usage") - .select("cost_usd, output_tokens") - .eq("user_id", userId); - - if (fallbackError) { - throw new Error(`Unable to load usage totals from daily_usage fallback (${fallbackError.message})`); - } - - const rows = (fallbackRows ?? []) as UsageFallbackRow[]; - return { - totalTokens: rows.reduce((sum, row) => sum + Number(row.output_tokens), 0), - totalCost: rows.reduce((sum, row) => sum + Number(row.cost_usd), 0), - }; - }; - - if (usageTotalsRes.error) { - console.error("get_user_usage_totals RPC failed; using direct daily_usage fallback", { - userId, - code: usageTotalsRes.error.code, - message: usageTotalsRes.error.message, - }); - - return loadFallbackUsageTotals(); - } - - const usageTotals = usageTotalsRes.data as UsageTotalsRpcRow | null; - const rpcTokens = usageTotals?.total_tokens; - - if (rpcTokens === null || rpcTokens === undefined) { - console.error("get_user_usage_totals returned no total_tokens; using direct daily_usage fallback", { - userId, - rpcKeys: usageTotals ? Object.keys(usageTotals) : [], - }); - - return loadFallbackUsageTotals(); - } - - return { - totalTokens: Number(rpcTokens), - totalCost: Number(usageTotals?.total_cost ?? 0), - }; -} - function formatLatestPosts(rows: LatestPostRow[]) { return rows .map((row) => { @@ -179,23 +123,6 @@ function SidebarFallback({ profile }: { profile: ShellProfile | null }) { ); } -function RightSidebarFallback() { - return ( -
- {[0, 1, 2].map((section) => ( -
- -
- - - -
-
- ))} -
- ); -} - async function DeferredSidebar({ userId, profile, @@ -252,22 +179,6 @@ async function DeferredSidebar({ ); } -async function DeferredRightSidebar({ - userId, -}: { - userId: string; -}) { - const supabase = await createClient(); - const usageTotals = await loadUsageTotals(supabase, userId); - - return ( - - ); -} - async function PhotoNudge({ userId, onboardingIncomplete, @@ -311,8 +222,9 @@ export default async function AppLayout({ // This check runs server-side as a safety net alongside proxy.ts // Public pages render with a guest layout below return ( -
- + +
+
@@ -321,7 +233,8 @@ export default async function AppLayout({
-
+
+ ); } @@ -341,39 +254,38 @@ export default async function AppLayout({ ); - const rightPanel = ( - }> - - - ); + const rightPanel = ; return ( - -
- {onboardingIncomplete && ( -
- Finish setting up your profile - - Complete onboarding - -
- )} - - - - - - {children} - -
-
+ + +
+ {onboardingIncomplete && ( +
+ Finish setting up your profile + + Complete onboarding + +
+ )} + + + + + + {children} + +
+
+
); } diff --git a/apps/web/app/(app)/post/new/CopyCommand.tsx b/apps/web/app/(app)/post/new/CopyCommand.tsx index 1c99f6c8..0b203486 100644 --- a/apps/web/app/(app)/post/new/CopyCommand.tsx +++ b/apps/web/app/(app)/post/new/CopyCommand.tsx @@ -1,50 +1,26 @@ "use client"; -import { useCallback, useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { useClipboardFeedback } from "@/lib/utils/useClipboardFeedback"; export function CopyCommand({ command }: { command: string }) { - const [copied, setCopied] = useState(false); - - const copy = useCallback(() => { - navigator.clipboard.writeText(command).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - }, [command]); + const { copied, copyText } = useClipboardFeedback(); return ( ); diff --git a/apps/web/app/(app)/u/[username]/page.tsx b/apps/web/app/(app)/u/[username]/page.tsx index 52119c25..f8339b4a 100644 --- a/apps/web/app/(app)/u/[username]/page.tsx +++ b/apps/web/app/(app)/u/[username]/page.tsx @@ -16,6 +16,8 @@ import { FeedList } from "@/components/app/feed/FeedList"; import { FollowButton } from "@/components/app/profile/FollowButton"; import { InviteButton } from "@/components/app/profile/InviteButton"; import { CrewPopover, type CrewMember } from "@/components/app/profile/CrewPopover"; +import { FirstSyncCommandCard } from "@/components/app/activation/FirstSyncCommandCard"; +import { GuestSignupCta } from "@/components/app/activation/GuestSignupCta"; import { formatCurrency, formatTokens } from "@/lib/utils/format"; import { enrichFeedPosts, getFeedCursor } from "@/lib/feed-enrichment"; import { firstRelation } from "@/lib/utils/first-relation"; @@ -185,6 +187,7 @@ export default async function ProfilePage({ const totalSpend = totalSpendRows?.reduce((s, r) => s + Number(r.cost_usd), 0) ?? 0; const lifetimeOutputTokens = totalSpendRows?.reduce((s, r) => s + Number(r.output_tokens), 0) ?? 0; + const hasUsage = (totalSpendRows?.length ?? 0) > 0; const postDateSet = new Set( ((postDates ?? []) as PostDateRow[]) @@ -326,48 +329,61 @@ export default async function ProfilePage({ - {/* Stats row */} -
-
-

Streak

-

- - {streak ?? 0} days -

-
-
-

Output Tokens

-

- - {formatTokens(lifetimeOutputTokens)} -

-
-
-

Total Spend

-

- ${formatCurrency(totalSpend)} -

+ {isOwn && !hasUsage ? ( +
+
- {(crewMembers ?? []).length > 0 && ( -
- + ) : ( + <> + {/* Stats row */} +
+
+

Streak

+

+ + {streak ?? 0} days +

+
+
+

Output Tokens

+

+ + {formatTokens(lifetimeOutputTokens)} +

+
+
+

Total Spend

+

+ ${formatCurrency(totalSpend)} +

+
+ {(crewMembers ?? []).length > 0 && ( +
+ +
+ )}
- )} -
- {/* Achievement badges */} - {(achievements && achievements.length > 0 || isOwn) && ( -
-

Achievements

- -
+ {/* Achievement badges */} + {(achievements && achievements.length > 0 || isOwn) && ( +
+

Achievements

+ +
+ )} + )}
+ {!authUserId && hasUsage && ( + + )} + {/* Contribution graph */} + {hasUsage && (

Contributions @@ -389,6 +405,7 @@ export default async function ProfilePage({ )}

+ )} {/* Posts */}
@@ -408,7 +425,9 @@ export default async function ProfilePage({ /> ) : (
- No activities yet. + {isOwn && !hasUsage + ? "Your first synced session will appear here." + : "No activities yet."}
)}
diff --git a/apps/web/app/(auth)/callback/route.ts b/apps/web/app/(auth)/callback/route.ts index f40056f4..01886726 100644 --- a/apps/web/app/(auth)/callback/route.ts +++ b/apps/web/app/(auth)/callback/route.ts @@ -1,7 +1,20 @@ import { NextResponse } from "next/server"; +import { after } from "@/lib/utils/after"; +import { ACTIVATION_ANONYMOUS_COOKIE, deriveActivationState } from "@/lib/analytics/activation"; +import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server"; import { createClient } from "@/lib/supabase/server"; import { getServiceClient } from "@/lib/supabase/service"; +function getCookieValue(cookieHeader: string | null, name: string): string | null { + if (!cookieHeader) return null; + const target = `${name}=`; + const entry = cookieHeader + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(target)); + return entry ? decodeURIComponent(entry.slice(target.length)) : null; +} + export async function GET(request: Request) { const { searchParams, origin: requestOrigin } = new URL(request.url); const origin = requestOrigin; @@ -26,6 +39,35 @@ export async function GET(request: Request) { .select("username, github_username, onboarding_completed") .eq("id", user.id) .single(); + const anonymousId = getCookieValue( + request.headers.get("cookie"), + ACTIVATION_ANONYMOUS_COOKIE, + ); + + after(async () => { + if (anonymousId) { + await identifyServerActivationUser({ + distinctId: user.id, + anonymousDistinctId: anonymousId, + properties: { + is_authenticated: true, + activation_state: "signed_up", + }, + }); + } + + if (!profile?.onboarding_completed) { + await captureServerActivationEvent({ + event: "signup_completed", + distinctId: user.id, + properties: { + surface: "auth_callback", + activation_state: deriveActivationState({ isAuthenticated: true }), + is_authenticated: true, + }, + }); + } + }); if (profile && !profile.username && profile.github_username) { const sanitized = profile.github_username diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx index f0d06e6a..5e870d93 100644 --- a/apps/web/app/(auth)/layout.tsx +++ b/apps/web/app/(auth)/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { PublicAnalytics } from "@/components/providers/PublicAnalytics"; export const metadata: Metadata = { title: "Sign in to Straude", @@ -13,8 +14,11 @@ export default function AuthLayout({ children: React.ReactNode; }) { return ( -
-
{children}
-
+ <> +
+
{children}
+
+ + ); } diff --git a/apps/web/app/(auth)/signup/page.tsx b/apps/web/app/(auth)/signup/page.tsx index 694e69ae..6d916796 100644 --- a/apps/web/app/(auth)/signup/page.tsx +++ b/apps/web/app/(auth)/signup/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { BoltIcon } from "@/components/landing/icons"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; +import { trackActivationEvent } from "@/lib/analytics/client"; export default function SignupPage() { const [email, setEmail] = useState(""); @@ -17,6 +18,12 @@ export default function SignupPage() { e.preventDefault(); setLoading(true); setError(null); + trackActivationEvent("signup_started", { + surface: "signup", + signup_method: "magic_link", + activation_state: "anonymous", + is_authenticated: false, + }); const supabase = createClient(); const { error } = await supabase.auth.signInWithOtp({ @@ -33,6 +40,12 @@ export default function SignupPage() { } async function handleGitHub() { + trackActivationEvent("signup_started", { + surface: "signup", + signup_method: "github", + activation_state: "anonymous", + is_authenticated: false, + }); const supabase = createClient(); await supabase.auth.signInWithOAuth({ provider: "github", diff --git a/apps/web/app/(landing)/layout.tsx b/apps/web/app/(landing)/layout.tsx index 26983436..63f55040 100644 --- a/apps/web/app/(landing)/layout.tsx +++ b/apps/web/app/(landing)/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { CookieConsentModal } from "@/components/landing/CookieConsentModal"; +import { PublicAnalytics } from "@/components/providers/PublicAnalytics"; export const metadata: Metadata = { title: { absolute: "Straude — Strava for Claude Code" }, @@ -15,6 +16,7 @@ export default function LandingLayout({ return ( <> {children} + ); diff --git a/apps/web/app/(landing)/open/page.tsx b/apps/web/app/(landing)/open/page.tsx index 38cfcda3..fb763da1 100644 --- a/apps/web/app/(landing)/open/page.tsx +++ b/apps/web/app/(landing)/open/page.tsx @@ -164,9 +164,7 @@ export default async function OpenStatsPage() { Daily, anonymized data from the Straude community.

- {stats.source === "snapshot" - ? "Showing the last successful snapshot while the live refresh recovers." - : "Updated daily from the latest successful Straude snapshot."} + Updated daily from the latest successful Straude snapshot.

{/* ---- Big Number Grid ----------------------------------------- */} diff --git a/apps/web/app/(onboarding)/layout.tsx b/apps/web/app/(onboarding)/layout.tsx index e07d636a..73f475be 100644 --- a/apps/web/app/(onboarding)/layout.tsx +++ b/apps/web/app/(onboarding)/layout.tsx @@ -1,6 +1,7 @@ import { createClient } from "@/lib/supabase/server"; import { getServiceClient } from "@/lib/supabase/service"; import { redirect } from "next/navigation"; +import { AppProviders } from "@/components/providers/AppProviders"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -35,8 +36,10 @@ export default async function OnboardingLayout({ } return ( -
-
{children}
-
+ +
+
{children}
+
+
); } diff --git a/apps/web/app/(onboarding)/onboarding/page.tsx b/apps/web/app/(onboarding)/onboarding/page.tsx index c02b0549..934d111b 100644 --- a/apps/web/app/(onboarding)/onboarding/page.tsx +++ b/apps/web/app/(onboarding)/onboarding/page.tsx @@ -7,18 +7,21 @@ import { BoltIcon } from "@/components/landing/icons"; import { Check, X, Loader2, ArrowRight, Copy } from "lucide-react"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; -import { Textarea } from "@/components/ui/Textarea"; -import { CountryPicker } from "@/components/ui/CountryPicker"; +import { trackActivationEvent } from "@/lib/analytics/client"; import { formatCurrency } from "@/lib/utils/format"; const SYNC_COMMAND = "npx straude@latest"; interface UsageStatus { has_data: boolean; + has_usage?: boolean; cost_usd?: number; total_tokens?: number; session_count?: number; top_model?: string | null; + latest_usage_id?: string; + latest_usage_at?: string | null; + latest_post_url?: string | null; } function formatTokens(n: number): string { @@ -32,17 +35,27 @@ function Step3LogSession({ username }: { username: string }) { const [copied, setCopied] = useState(false); const [phase, setPhase] = useState<"waiting" | "success">("waiting"); const [data, setData] = useState(null); + const [hasExistingUsage, setHasExistingUsage] = useState(false); + const [completionError, setCompletionError] = useState(null); + const activationTrackedRef = useRef(false); + const activationPersistedRef = useRef(false); + const commandCopiedRef = useRef(false); const handleCopy = useCallback(() => { navigator.clipboard.writeText(SYNC_COMMAND).then(() => { + commandCopiedRef.current = true; + trackActivationEvent("sync_command_copied", { + surface: "onboarding", + command: SYNC_COMMAND, + activation_state: "sync_command_copied", + is_authenticated: true, + }); setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, []); - // Poll for NEW session data (ignore pre-existing usage) const intervalRef = useRef>(undefined); - const baselineRef = useRef(null); useEffect(() => { let active = true; @@ -52,17 +65,11 @@ function Step3LogSession({ username }: { username: string }) { const res = await fetch("/api/usage/status"); if (!res.ok) return; const json: UsageStatus = await res.json(); - const count = json.session_count ?? 0; + const hasUsage = json.has_usage ?? json.has_data; - // First poll: record the baseline - if (baselineRef.current === null) { - baselineRef.current = count; - return; - } - - // Only transition when sessions increase beyond baseline - if (count > baselineRef.current && active) { + if (hasUsage && active) { setData(json); + setHasExistingUsage(!commandCopiedRef.current); setPhase("success"); if (intervalRef.current) clearInterval(intervalRef.current); } @@ -79,6 +86,49 @@ function Step3LogSession({ username }: { username: string }) { }; }, []); + useEffect(() => { + if (phase !== "success" || !data || activationPersistedRef.current) return; + + activationPersistedRef.current = true; + const observedUsage = data; + + async function persistActivation() { + const res = await fetch("/api/users/me", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ onboarding_completed: true }), + }); + + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + setCompletionError( + typeof payload.error === "string" + ? payload.error + : "We saw your usage, but setup could not be completed. Refresh and try again.", + ); + return; + } + + if (!activationTrackedRef.current) { + activationTrackedRef.current = true; + trackActivationEvent("activation_completed", { + surface: "onboarding", + activation_state: "activated", + is_authenticated: true, + session_count: observedUsage.session_count, + total_tokens: observedUsage.total_tokens, + total_cost_usd: observedUsage.cost_usd, + has_existing_usage: hasExistingUsage, + "$insert_id": observedUsage.latest_usage_id + ? `activation_completed:${observedUsage.latest_usage_id}` + : "activation_completed:onboarding", + }); + } + } + + void persistActivation(); + }, [data, hasExistingUsage, phase]); + if (phase === "success" && data) { return ( <> @@ -96,7 +146,8 @@ function Step3LogSession({ username }: { username: string }) {

- Your usage is live. Here's what we captured. + Your usage is live. Straude can now build your streak, spend totals, + and shareable session history.

{/* Stats grid */} @@ -127,7 +178,23 @@ function Step3LogSession({ username }: { username: string }) { + {completionError && ( +

+ {completionError} +

+ )} +
+ {data.latest_post_url && ( + + )}
@@ -229,12 +296,6 @@ export default function OnboardingPage() { const [usernameStatus, setUsernameStatus] = useState("idle"); const debounceRef = useRef>(undefined); - // Step 2 - const [bio, setBio] = useState(""); - const [heardAbout, setHeardAbout] = useState(""); - const [country, setCountry] = useState(""); - const [githubUsername, setGithubUsername] = useState(""); - // Auto-detect timezone const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -244,9 +305,6 @@ export default function OnboardingPage() { const res = await fetch("/api/users/me"); if (res.ok) { const profile = await res.json(); - if (profile.github_username) { - setGithubUsername(profile.github_username); - } // Pre-fill username: use existing username (e.g. auto-claimed from GitHub), // otherwise suggest from GitHub handle if (profile.username) { @@ -262,9 +320,6 @@ export default function OnboardingPage() { } } if (profile.display_name) setDisplayName(profile.display_name); - if (profile.country) setCountry(profile.country); - if (profile.bio) setBio(profile.bio); - if (profile.heard_about) setHeardAbout(profile.heard_about); } } loadProfile(); @@ -300,20 +355,15 @@ export default function OnboardingPage() { }; }, [username]); - async function handleFinish() { + async function handleProfileSave() { setSaving(true); setError(null); const body: Record = { timezone, - onboarding_completed: true, }; if (username) body.username = username; if (displayName) body.display_name = displayName; - if (bio) body.bio = bio; - body.heard_about = heardAbout.trim() || null; - if (country) body.country = country; - if (githubUsername) body.github_username = githubUsername; const res = await fetch("/api/users/me", { method: "PATCH", @@ -328,6 +378,7 @@ export default function OnboardingPage() { return; } + setSaving(false); setStep(3); } @@ -414,7 +465,14 @@ export default function OnboardingPage() {
-
- - Skip for now - -
-
@@ -449,74 +501,25 @@ export default function OnboardingPage() { className="text-2xl font-medium tracking-tight" style={{ letterSpacing: "-0.03em" }} > - Almost there + Ready to sync

- Optional details to round out your profile. You can always change these - later in Settings. + Your first sync unlocks the useful parts of Straude: spend, tokens, + streaks, and a shareable session you can edit after it lands.

-
-
- -