diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 7667a2922..36873cef6 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -1,9 +1,12 @@ -import { redirect } from "next/navigation"; +import SetupEntry from "@/components/Onboarding/SetupEntry"; /** - * `/setup` — the welcome email's "Confirm your roster" CTA target. Sends the - * user straight into the interactive setup flow at the first step (roster). + * `/setup` — the welcome email's "Confirm your roster" CTA target, and the + * route the authenticated home forwards an incomplete account to. Opens the + * sequence at the account's DERIVED step rather than assuming step 1 + * (chat#1889); the derived-step resolution needs client state, so the page + * mounts `SetupEntry`. */ export default function SetupPage() { - redirect("/setup/artists"); + return ; } diff --git a/components/Home/HomePage.tsx b/components/Home/HomePage.tsx index d83353126..9ab1175ae 100644 --- a/components/Home/HomePage.tsx +++ b/components/Home/HomePage.tsx @@ -1,15 +1,16 @@ "use client"; import { useMiniKit } from "@coinbase/onchainkit/minikit"; +import { useRouter } from "next/navigation"; import NewChatBootstrap from "../VercelChat/NewChatBootstrap"; import OnboardingChecklist from "@/components/Onboarding/OnboardingChecklist"; -import OnboardingSequence from "@/components/Onboarding/OnboardingSequence"; import { useOnboardingGate } from "@/hooks/useOnboardingGate"; import { useEffect } from "react"; import { UIMessage } from "ai"; const HomePage = ({ initialMessages }: { initialMessages?: UIMessage[] }) => { const { setFrameReady, isFrameReady } = useMiniKit(); + const router = useRouter(); const onboarding = useOnboardingGate(); useEffect(() => { @@ -18,21 +19,20 @@ const HomePage = ({ initialMessages }: { initialMessages?: UIMessage[] }) => { } }, [setFrameReady, isFrameReady]); - // Incomplete accounts resume the sequence at their derived step on every - // landing; skip drops to the app with the checklist pinned as a persistent - // reminder; activated accounts only ever see the normal home - // (recoupable/chat#1867). - if (onboarding.view === "sequence" && onboarding.step !== "complete") { - return ( -
- -
- ); - } + // One canonical onboarding surface (chat#1889): an incomplete account is + // forwarded into `/setup`, which opens at its derived step. Home used to host + // a second, card-based sequence that only linked out to the generic app pages, + // so a direct signup never saw the interactive steps the welcome email's + // `/setup/*` links reach. Skip still drops to the app with the checklist + // pinned (the gate stays soft — see SetupSkipLink). + const shouldEnterSetup = + onboarding.view === "sequence" && onboarding.step !== "complete"; + + useEffect(() => { + if (shouldEnterSetup) { + router.replace("/setup"); + } + }, [shouldEnterSetup, router]); return (
diff --git a/components/Home/__tests__/HomePage.onboarding.test.tsx b/components/Home/__tests__/HomePage.onboarding.test.tsx index fdb152da9..fbe155248 100644 --- a/components/Home/__tests__/HomePage.onboarding.test.tsx +++ b/components/Home/__tests__/HomePage.onboarding.test.tsx @@ -4,6 +4,9 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import HomePage from "@/components/Home/HomePage"; +const replace = vi.fn(); +const SKIP_KEY = "recoup-onboarding-skipped:acct-test"; + vi.mock("@coinbase/onchainkit/minikit", () => ({ useMiniKit: () => ({ setFrameReady: vi.fn(), isFrameReady: true }), })); @@ -12,16 +15,8 @@ vi.mock("@/components/VercelChat/NewChatBootstrap", () => ({ default: () =>
, })); -vi.mock("next/link", () => ({ - default: ({ - href, - children, - ...rest - }: React.ComponentProps<"a"> & { href: string }) => ( - - {children} - - ), +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace, push: vi.fn() }), })); vi.mock("@/providers/UserProvder", () => ({ @@ -41,28 +36,36 @@ vi.mock("@/hooks/useOnboardingState", () => ({ }), })); -describe("HomePage onboarding gate (single source of truth)", () => { +describe("HomePage onboarding gate — one canonical surface", () => { beforeEach(() => { window.sessionStorage.clear(); + replace.mockClear(); + }); + + it("forwards an incomplete account into /setup instead of hosting its own sequence", () => { + render(); + expect(replace).toHaveBeenCalledWith("/setup"); }); - it("renders the sequence with the step card as an h3 under the container h2", () => { + it("renders no competing step surface of its own", () => { render(); + // The card-based sequence (OnboardingSequence + OnboardingStepCard) is gone: + // it only linked out to the generic app pages, so a direct signup never + // reached the interactive steps the welcome email's /setup/* links do. expect( - screen.getByRole("heading", { - level: 2, + screen.queryByRole("heading", { name: /finish setting up your account/i, }), - ).toBeDefined(); + ).toBeNull(); expect( - screen.getByRole("heading", { level: 3, name: /confirm your artists/i }), - ).toBeDefined(); - expect(screen.queryAllByRole("heading", { level: 1 })).toHaveLength(0); + screen.queryByRole("heading", { name: /confirm your artists/i }), + ).toBeNull(); }); - it("skip drops to the app with the pinned checklist", () => { + it("skip drops to the app with the pinned checklist and does not forward", () => { + window.sessionStorage.setItem(SKIP_KEY, "1"); render(); - fireEvent.click(screen.getByRole("button", { name: /skip for now/i })); + expect(replace).not.toHaveBeenCalled(); expect(screen.getByTestId("chat-bootstrap")).toBeDefined(); expect( screen.getByRole("complementary", { name: /onboarding checklist/i }), @@ -70,8 +73,8 @@ describe("HomePage onboarding gate (single source of truth)", () => { }); it("positions the checklist inside the content area, not fixed to the viewport under the right rail", () => { + window.sessionStorage.setItem(SKIP_KEY, "1"); render(); - fireEvent.click(screen.getByRole("button", { name: /skip for now/i })); const checklist = screen.getByRole("complementary", { name: /onboarding checklist/i, }); @@ -83,17 +86,15 @@ describe("HomePage onboarding gate (single source of truth)", () => { }); it("has no dismiss control — the checklist is a persistent reminder", () => { + window.sessionStorage.setItem(SKIP_KEY, "1"); render(); - fireEvent.click(screen.getByRole("button", { name: /skip for now/i })); expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull(); }); - it("the Continue button re-opens the sequence", () => { + it("the Continue button re-arms the gate, forwarding back into /setup", () => { + window.sessionStorage.setItem(SKIP_KEY, "1"); render(); - fireEvent.click(screen.getByRole("button", { name: /skip for now/i })); fireEvent.click(screen.getByRole("button", { name: /continue/i })); - expect( - screen.getByRole("heading", { level: 3, name: /confirm your artists/i }), - ).toBeDefined(); + expect(replace).toHaveBeenCalledWith("/setup"); }); }); diff --git a/components/Onboarding/FirstTaskStep.tsx b/components/Onboarding/FirstTaskStep.tsx index 1977a5f8d..72f22fdd2 100644 --- a/components/Onboarding/FirstTaskStep.tsx +++ b/components/Onboarding/FirstTaskStep.tsx @@ -6,13 +6,17 @@ import useCatalogs from "@/hooks/useCatalogs"; import { useNewChatBootstrap } from "@/hooks/useNewChatBootstrap"; import { buildFirstTaskPrompt } from "@/lib/onboarding/buildFirstTaskPrompt"; import FirstTaskReportRun from "./FirstTaskReportRun"; +import SetupProgress from "./SetupProgress"; +import SetupSkipLink from "./SetupSkipLink"; /** - * Onboarding sequence final step (chat#1867): pre-run the first weekly - * catalog report, show it finished, then ask "get this every Monday?". - * Self-contained — provisions its own chat session (same infrastructure - * a normal send uses) so it can slot into the OnboardingSequence - * container later without depending on it. + * Final step of the canonical `/setup/*` sequence (chat#1867, chat#1889), + * mounted by `/setup/tasks`: pre-run the first weekly catalog report, show it + * finished, then ask "get this every Monday?". Self-contained — provisions its + * own chat session (the same infrastructure a normal send uses). + * + * Carries the gate's "skip for now" escape hatch, since home forwards + * incomplete accounts into the sequence. */ const FirstTaskStep = () => { const { selectedArtist, isLoading: isArtistsLoading } = useArtistProvider(); @@ -33,6 +37,7 @@ const FirstTaskStep = () => { return (
+

Your first weekly report @@ -84,6 +89,7 @@ const FirstTaskStep = () => { catalogName={catalogName} /> )} +

); }; diff --git a/components/Onboarding/OnboardingCheckpointList.tsx b/components/Onboarding/OnboardingCheckpointList.tsx index b3fe4d4ad..f38240ef2 100644 --- a/components/Onboarding/OnboardingCheckpointList.tsx +++ b/components/Onboarding/OnboardingCheckpointList.tsx @@ -2,12 +2,14 @@ import { Check } from "lucide-react"; import { cn } from "@/lib/utils"; -import { getOnboardingStepContent } from "@/lib/onboarding/getOnboardingStepContent"; +import { getOnboardingStepTitle } from "@/lib/onboarding/getOnboardingStepTitle"; import type { OnboardingCheckpoint } from "@/lib/onboarding/types"; /** - * Read-only list of activation checkpoints with completion state. Shared - * by the onboarding sequence (progress) and the pinned checklist. + * Read-only list of activation checkpoints with completion state, used by the + * pinned checklist. Titles come from `getOnboardingStepTitle` — the placeholder + * step-card content it used to read is gone (chat#1889), since each step now + * renders its own real heading inside `/setup/*`. */ const OnboardingCheckpointList = ({ checkpoints, @@ -35,7 +37,7 @@ const OnboardingCheckpointList = ({ : "text-foreground", )} > - {getOnboardingStepContent(checkpoint.id).title} + {getOnboardingStepTitle(checkpoint.id)} {checkpoint.complete ? "(complete)" : "(incomplete)"} diff --git a/components/Onboarding/OnboardingSequence.tsx b/components/Onboarding/OnboardingSequence.tsx deleted file mode 100644 index 9d889cddf..000000000 --- a/components/Onboarding/OnboardingSequence.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import OnboardingCheckpointList from "@/components/Onboarding/OnboardingCheckpointList"; -import OnboardingStepCard from "@/components/Onboarding/OnboardingStepCard"; -import type { - OnboardingCheckpoint, - OnboardingStepId, -} from "@/lib/onboarding/types"; - -interface OnboardingSequenceProps { - step: OnboardingStepId; - checkpoints: OnboardingCheckpoint[]; - onSkip: () => void; -} - -/** - * The onboarding sequence container (recoupable/chat#1867): renders the - * derived current step with overall progress and an always-visible - * "skip for now" escape hatch — a soft gate, never a wall. - */ -const OnboardingSequence = ({ - step, - checkpoints, - onSkip, -}: OnboardingSequenceProps) => { - const stepNumber = checkpoints.findIndex((c) => c.id === step) + 1; - - return ( -
-
-

- Set up Recoup · step {stepNumber} of {checkpoints.length} -

-

- Finish setting up your account -

-
- - - -
- ); -}; - -export default OnboardingSequence; diff --git a/components/Onboarding/OnboardingStepCard.tsx b/components/Onboarding/OnboardingStepCard.tsx deleted file mode 100644 index 3d39b7c7a..000000000 --- a/components/Onboarding/OnboardingStepCard.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { getOnboardingStepContent } from "@/lib/onboarding/getOnboardingStepContent"; -import type { OnboardingStepId } from "@/lib/onboarding/types"; - -/** - * Placeholder card for a single onboarding step (recoupable/chat#1867). - * Each step's full in-sequence experience ships in sibling PRs; until then - * the card states the step's purpose and links to the existing page where - * it can be completed today. - */ -const OnboardingStepCard = ({ step }: { step: OnboardingStepId }) => { - const { title, description, href, linkLabel } = - getOnboardingStepContent(step); - - return ( -
-

- {title} -

-

{description}

- - {linkLabel} - -
- ); -}; - -export default OnboardingStepCard; diff --git a/components/Onboarding/RosterSocialsFlow.tsx b/components/Onboarding/RosterSocialsFlow.tsx index 825decedd..d21c5b9b8 100644 --- a/components/Onboarding/RosterSocialsFlow.tsx +++ b/components/Onboarding/RosterSocialsFlow.tsx @@ -3,19 +3,21 @@ import { useState } from "react"; import ConfirmRosterStep from "./ConfirmRosterStep"; import RosterVerifiedPanel from "./RosterVerifiedPanel"; +import SetupProgress from "./SetupProgress"; +import SetupSkipLink from "./SetupSkipLink"; import VerifySocialsStep from "./VerifySocialsStep"; type FlowStep = "roster" | "socials" | "done"; /** - * Standalone container for the roster + socials onboarding steps so the - * slice is user-testable at /onboarding/roster today. The sibling - * onboarding-sequence router (chat#1867) mounts `ConfirmRosterStep` and - * `VerifySocialsStep` directly with its own state-derived stepping. + * Container for the roster + socials steps of the canonical `/setup/*` + * sequence (chat#1889), mounted by `/setup/artists` and `/setup/socials`. * * `initialStep` lets a deep link open the flow directly at a given step (e.g. - * the welcome email's "verify socials" link → `/setup/socials`); it defaults to - * "roster" so existing mounts are unchanged. + * the welcome email's "verify socials" link → `/setup/socials`). + * + * Carries the gate's "skip for now" escape hatch: home forwards incomplete + * accounts here, so without it the soft gate would become a wall. */ const RosterSocialsFlow = ({ initialStep = "roster", @@ -25,11 +27,9 @@ const RosterSocialsFlow = ({ const [step, setStep] = useState(initialStep); return ( -
+
{step !== "done" && ( -

- Step {step === "roster" ? "1" : "2"} of 2 -

+ )} {step === "roster" && ( setStep("socials")} /> @@ -38,6 +38,7 @@ const RosterSocialsFlow = ({ setStep("done")} /> )} {step === "done" && } + {step !== "done" && }
); }; diff --git a/components/Onboarding/SetupEntry.tsx b/components/Onboarding/SetupEntry.tsx new file mode 100644 index 000000000..cc4e6d42c --- /dev/null +++ b/components/Onboarding/SetupEntry.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useOnboardingState } from "@/hooks/useOnboardingState"; +import { getSetupPathForStep } from "@/lib/onboarding/getSetupPathForStep"; + +/** + * `/setup` entry gate: forwards to the step the account is actually on + * (chat#1889). The route used to hard-redirect to `/setup/artists`, which sent + * a signup who had already confirmed their roster back to step 1 — the derived + * step is the source of truth, so entry points must resolve it rather than + * assume the beginning. + * + * Renders a skeleton (never the first step) until every checkpoint source has + * resolved, so a slow read can't flash the wrong step before redirecting. + */ +const SetupEntry = () => { + const router = useRouter(); + const { isReady, step } = useOnboardingState(); + + useEffect(() => { + if (!isReady) return; + router.replace(getSetupPathForStep(step)); + }, [isReady, step, router]); + + return ( +
+ + + +
+ ); +}; + +export default SetupEntry; diff --git a/components/Onboarding/SetupProgress.tsx b/components/Onboarding/SetupProgress.tsx new file mode 100644 index 000000000..10a237139 --- /dev/null +++ b/components/Onboarding/SetupProgress.tsx @@ -0,0 +1,20 @@ +import { getOnboardingStepPosition } from "@/lib/onboarding/getOnboardingStepPosition"; +import type { OnboardingStepId } from "@/lib/onboarding/types"; + +/** + * Progress line for a `/setup/*` step, counted against the one shared + * checkpoint vocabulary (chat#1889). Every step surface renders this instead + * of its own numbering, so the sequence and the pinned checklist can never + * disagree about how many steps there are. + */ +const SetupProgress = ({ step }: { step: OnboardingStepId }) => { + const { number, total } = getOnboardingStepPosition(step); + + return ( +

+ Step {number} of {total} +

+ ); +}; + +export default SetupProgress; diff --git a/components/Onboarding/SetupSkipLink.tsx b/components/Onboarding/SetupSkipLink.tsx new file mode 100644 index 000000000..a9ba08b2e --- /dev/null +++ b/components/Onboarding/SetupSkipLink.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useOnboardingSessionFlags } from "@/hooks/useOnboardingSessionFlags"; +import { useUserProvider } from "@/providers/UserProvder"; + +/** + * The onboarding gate's escape hatch, on the `/setup/*` surface (chat#1889). + * + * Home now forwards an incomplete account into `/setup`, so skip has to live + * here or the soft gate becomes a wall. Setting the session flag makes + * `getOnboardingView` return "checklist", so home renders the app with the + * checklist pinned instead of forwarding again. + */ +const SetupSkipLink = () => { + const router = useRouter(); + const { userData } = useUserProvider(); + const { skip } = useOnboardingSessionFlags(userData?.account_id ?? ""); + + const handleSkip = () => { + skip(); + router.push("/"); + }; + + return ( + + ); +}; + +export default SetupSkipLink; diff --git a/components/Onboarding/__tests__/SetupProgress.test.tsx b/components/Onboarding/__tests__/SetupProgress.test.tsx new file mode 100644 index 000000000..63fd96979 --- /dev/null +++ b/components/Onboarding/__tests__/SetupProgress.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import RosterSocialsFlow from "@/components/Onboarding/RosterSocialsFlow"; +import SetupProgress from "@/components/Onboarding/SetupProgress"; +import { vi } from "vitest"; + +vi.mock("@/providers/ArtistProvider", () => ({ + useArtistProvider: () => ({ sorted: [], isLoading: false }), +})); + +vi.mock("@/providers/UserProvder", () => ({ + useUserProvider: () => ({ userData: { account_id: "acct-test" } }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("@/hooks/onboarding/useAddRosterArtist", () => ({ + useAddRosterArtist: () => ({ addArtist: vi.fn(), isAdding: false }), +})); + +describe("SetupProgress", () => { + it("counts against the shared 4-checkpoint vocabulary", () => { + render(); + expect(screen.getByText(/step 2 of 4/i)).toBeDefined(); + }); + + it("puts the final step last, not at its own private count", () => { + render(); + expect(screen.getByText(/step 4 of 4/i)).toBeDefined(); + }); +}); + +describe("RosterSocialsFlow progress", () => { + it("no longer renders its own 'of 2' denominator", () => { + render(); + // Regression guard (chat#1889): the flow used to count "Step 1 of 2" from + // local state while the checklist counted 4 — one account, two answers. + expect(screen.queryByText(/of 2$/i)).toBeNull(); + expect(screen.getByText(/step 1 of 4/i)).toBeDefined(); + }); +}); diff --git a/lib/onboarding/__tests__/getOnboardingStepContent.test.ts b/lib/onboarding/__tests__/getOnboardingStepContent.test.ts deleted file mode 100644 index 9239fc3b6..000000000 --- a/lib/onboarding/__tests__/getOnboardingStepContent.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getOnboardingStepContent } from "@/lib/onboarding/getOnboardingStepContent"; -import { ONBOARDING_STEP_IDS } from "@/lib/onboarding/types"; - -describe("getOnboardingStepContent", () => { - it("provides a titled card with an in-app link for every step", () => { - for (const id of ONBOARDING_STEP_IDS) { - const content = getOnboardingStepContent(id); - expect(content.title.length, id).toBeGreaterThan(0); - expect(content.description.length, id).toBeGreaterThan(0); - expect(content.linkLabel.length, id).toBeGreaterThan(0); - expect(content.href.startsWith("/"), id).toBe(true); - } - }); - - it("links each step to the relevant existing page", () => { - expect(getOnboardingStepContent("artists").href).toBe("/artists"); - expect(getOnboardingStepContent("socials").href).toBe("/artists"); - expect(getOnboardingStepContent("catalog").href).toBe("/catalogs"); - expect(getOnboardingStepContent("task").href).toBe("/tasks"); - }); -}); diff --git a/lib/onboarding/__tests__/getOnboardingStepPosition.test.ts b/lib/onboarding/__tests__/getOnboardingStepPosition.test.ts new file mode 100644 index 000000000..9a44fd629 --- /dev/null +++ b/lib/onboarding/__tests__/getOnboardingStepPosition.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { getOnboardingStepPosition } from "@/lib/onboarding/getOnboardingStepPosition"; +import { ONBOARDING_STEP_IDS } from "@/lib/onboarding/types"; + +describe("getOnboardingStepPosition", () => { + it("numbers each step against the one shared checkpoint vocabulary", () => { + expect(getOnboardingStepPosition("artists")).toEqual({ + number: 1, + total: 4, + }); + expect(getOnboardingStepPosition("socials")).toEqual({ + number: 2, + total: 4, + }); + expect(getOnboardingStepPosition("catalog")).toEqual({ + number: 3, + total: 4, + }); + expect(getOnboardingStepPosition("task")).toEqual({ number: 4, total: 4 }); + }); + + it("derives the denominator from ONBOARDING_STEP_IDS, never a private count", () => { + // The regression this guards: RosterSocialsFlow rendered "Step 1 of 2" from + // its own local state while the checklist said "of 4" — same account, two + // denominators (chat#1889). + for (const step of ONBOARDING_STEP_IDS) { + expect(getOnboardingStepPosition(step).total).toBe( + ONBOARDING_STEP_IDS.length, + ); + } + }); + + it("is 1-indexed for display", () => { + expect(getOnboardingStepPosition(ONBOARDING_STEP_IDS[0]).number).toBe(1); + }); +}); diff --git a/lib/onboarding/__tests__/getOnboardingStepTitle.test.ts b/lib/onboarding/__tests__/getOnboardingStepTitle.test.ts new file mode 100644 index 000000000..7ca8a6d7a --- /dev/null +++ b/lib/onboarding/__tests__/getOnboardingStepTitle.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { getOnboardingStepTitle } from "@/lib/onboarding/getOnboardingStepTitle"; +import { ONBOARDING_STEP_IDS } from "@/lib/onboarding/types"; + +describe("getOnboardingStepTitle", () => { + it("names each checkpoint in the product's flow language", () => { + expect(getOnboardingStepTitle("artists")).toBe("Confirm your artists"); + expect(getOnboardingStepTitle("socials")).toBe("Verify socials"); + expect(getOnboardingStepTitle("catalog")).toBe("Claim your catalog"); + expect(getOnboardingStepTitle("task")).toBe("Schedule your first report"); + }); + + it("covers every step id, so the checklist can never render undefined", () => { + for (const step of ONBOARDING_STEP_IDS) { + expect(getOnboardingStepTitle(step)).toBeTruthy(); + } + }); +}); diff --git a/lib/onboarding/__tests__/getSetupPathForStep.test.ts b/lib/onboarding/__tests__/getSetupPathForStep.test.ts new file mode 100644 index 000000000..f481d1f0e --- /dev/null +++ b/lib/onboarding/__tests__/getSetupPathForStep.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { getSetupPathForStep } from "@/lib/onboarding/getSetupPathForStep"; +import { ONBOARDING_STEP_IDS } from "@/lib/onboarding/types"; + +describe("getSetupPathForStep", () => { + it("maps every derived step to its canonical /setup route", () => { + expect(getSetupPathForStep("artists")).toBe("/setup/artists"); + expect(getSetupPathForStep("socials")).toBe("/setup/socials"); + expect(getSetupPathForStep("catalog")).toBe("/setup/catalog"); + expect(getSetupPathForStep("task")).toBe("/setup/tasks"); + }); + + it("sends a complete account home, not into the sequence", () => { + expect(getSetupPathForStep("complete")).toBe("/"); + }); + + it("covers every step id, so a new checkpoint can't route to undefined", () => { + for (const step of ONBOARDING_STEP_IDS) { + expect(getSetupPathForStep(step)).toMatch(/^\/setup\//); + } + }); +}); diff --git a/lib/onboarding/getOnboardingStepContent.ts b/lib/onboarding/getOnboardingStepContent.ts deleted file mode 100644 index 778cdf8c0..000000000 --- a/lib/onboarding/getOnboardingStepContent.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { OnboardingStepId } from "@/lib/onboarding/types"; - -export interface OnboardingStepContent { - title: string; - description: string; - href: string; - linkLabel: string; -} - -const STEP_CONTENT: Record = { - artists: { - title: "Confirm your artists", - description: - "Confirm the artists you manage and add the rest of your roster so Recoup works for all of them.", - href: "/artists", - linkLabel: "Open your roster", - }, - socials: { - title: "Verify socials", - description: - "Check the social profiles matched to each artist so the data behind your reports is right.", - href: "/artists", - linkLabel: "Review artist profiles", - }, - catalog: { - title: "Claim your catalog", - description: - "Connect the catalog behind your valuation so Recoup can measure and track it over time.", - href: "/catalogs", - linkLabel: "Open catalogs", - }, - task: { - title: "Schedule your first report", - description: - "Set up a recurring task and Recoup will keep working your catalog every week.", - href: "/tasks", - linkLabel: "Open tasks", - }, -}; - -/** - * Placeholder card copy for each onboarding step. Each step's full - * experience ships in sibling PRs on recoupable/chat#1867; until then the - * card links to the existing page where the step can be completed today. - */ -export function getOnboardingStepContent( - step: OnboardingStepId, -): OnboardingStepContent { - return STEP_CONTENT[step]; -} diff --git a/lib/onboarding/getOnboardingStepPosition.ts b/lib/onboarding/getOnboardingStepPosition.ts new file mode 100644 index 000000000..c1cbb49e6 --- /dev/null +++ b/lib/onboarding/getOnboardingStepPosition.ts @@ -0,0 +1,25 @@ +import { ONBOARDING_STEP_IDS } from "@/lib/onboarding/types"; +import type { OnboardingStepId } from "@/lib/onboarding/types"; + +export interface OnboardingStepPosition { + /** 1-indexed position, for display. */ + number: number; + total: number; +} + +/** + * Where a step sits in the ONE shared checkpoint vocabulary (chat#1889). + * + * Both the numerator and the denominator come from `ONBOARDING_STEP_IDS`, so + * no surface can invent its own count: `RosterSocialsFlow` used to render + * "Step 1 of 2" from its local flow state while the checklist counted 4, which + * told the same account two different things about how much was left. + */ +export function getOnboardingStepPosition( + step: OnboardingStepId, +): OnboardingStepPosition { + return { + number: ONBOARDING_STEP_IDS.indexOf(step) + 1, + total: ONBOARDING_STEP_IDS.length, + }; +} diff --git a/lib/onboarding/getOnboardingStepTitle.ts b/lib/onboarding/getOnboardingStepTitle.ts new file mode 100644 index 000000000..55c01910f --- /dev/null +++ b/lib/onboarding/getOnboardingStepTitle.ts @@ -0,0 +1,19 @@ +import type { OnboardingStepId } from "@/lib/onboarding/types"; + +/** + * Display title for each activation checkpoint, in the product's flow + * language. Replaces the placeholder `getOnboardingStepContent` card copy + * (chat#1889): the steps now render their own real headings inside + * `/setup/*`, so the only thing still needed centrally is a short label for + * the read-only checkpoint list. + */ +const STEP_TITLES: Record = { + artists: "Confirm your artists", + socials: "Verify socials", + catalog: "Claim your catalog", + task: "Schedule your first report", +}; + +export function getOnboardingStepTitle(step: OnboardingStepId): string { + return STEP_TITLES[step]; +} diff --git a/lib/onboarding/getSetupPathForStep.ts b/lib/onboarding/getSetupPathForStep.ts new file mode 100644 index 000000000..b83f1a119 --- /dev/null +++ b/lib/onboarding/getSetupPathForStep.ts @@ -0,0 +1,24 @@ +import type { OnboardingStep, OnboardingStepId } from "@/lib/onboarding/types"; + +/** + * The canonical `/setup/*` route for each onboarding step (chat#1889). + * `/setup` is the one onboarding sequence, so every entry point — the welcome + * email, the catalog-report CTA, the retired `/onboarding/*` mounts, and the + * authenticated home — resolves a DERIVED step through this map instead of + * hard-coding a surface. A `Record` keyed by step id (not a partial lookup) + * so adding a checkpoint is a compile error until it has a destination. + */ +const SETUP_PATHS: Record = { + artists: "/setup/artists", + socials: "/setup/socials", + catalog: "/setup/catalog", + task: "/setup/tasks", +}; + +/** + * Resolves the derived onboarding step to the route that renders it. A + * "complete" account belongs on the app home, never inside the sequence. + */ +export function getSetupPathForStep(step: OnboardingStep): string { + return step === "complete" ? "/" : SETUP_PATHS[step]; +}