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 (
-
);
};
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];
+}