From cb7472c12cb0cd36c662423407c27341144cb27c Mon Sep 17 00:00:00 2001
From: Sweets Sweetman
Date: Thu, 23 Jul 2026 06:12:48 -0500
Subject: [PATCH 1/3] feat(onboarding): converge funnel entry onto the unified
OnboardingSequence
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The funnel-entry deep-link (/onboarding/first-task) rendered a standalone
FirstTaskStep mount — a surface distinct from the unified OnboardingSequence
that direct-chat signups see. Redirect it to the home gate, which renders
OnboardingSequence at the account's derived step, so both entry paths converge
on one onboarding experience and the funnel CTA always lands at the correct
step rather than a hard-coded one.
Deferred (noted in PR): fully retire the interim standalone /onboarding/*
mounts and repoint external deep-links.
Refs chat#1885.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/onboarding/first-task/page.tsx | 20 +++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/app/onboarding/first-task/page.tsx b/app/onboarding/first-task/page.tsx
index 2db228f60..f86f9fc59 100644
--- a/app/onboarding/first-task/page.tsx
+++ b/app/onboarding/first-task/page.tsx
@@ -1,10 +1,20 @@
-import FirstTaskStep from "@/components/Onboarding/FirstTaskStep";
+import { redirect } from "next/navigation";
/**
- * Standalone mount for the onboarding first-task step (chat#1867) so
- * the step is user-testable before the OnboardingSequence container
- * exists. The sequence PR will mount directly.
+ * Funnel-entry convergence (chat#1885): marketing-funnel signups used to
+ * deep-link to this standalone first-task mount, a surface distinct from the
+ * unified `OnboardingSequence` that direct-chat signups see. Route the funnel
+ * entry through the same surface by sending it to the home gate, which renders
+ * `OnboardingSequence` at the account's DERIVED step (`useOnboardingGate` →
+ * `HomePage`) — so both entry paths converge on one onboarding experience and
+ * the funnel CTA always lands at the correct step, never a hard-coded one.
+ *
+ * Deferred (see PR): fully retire the interim standalone `/onboarding/*`
+ * mounts (this route + `/onboarding/roster`) and repoint any external
+ * deep-links once nothing references them.
*/
-const FirstTaskOnboardingPage = () => ;
+const FirstTaskOnboardingPage = () => {
+ redirect("/");
+};
export default FirstTaskOnboardingPage;
From 3d26e22d4233b0deb9b0fb2ecf8bcafa8c9f2294 Mon Sep 17 00:00:00 2001
From: Sweets Sweetman
Date: Sun, 26 Jul 2026 23:26:00 -0500
Subject: [PATCH 2/3] fix(onboarding): repoint funnel entry at /setup/tasks,
not home
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
chat#1887 previously redirected /onboarding/first-task to `/`. That route's
only internal referrer is the catalog report's primary CTA, which is where
both the marketing valuation funnel and the valuation email land — so the
redirect sent converting users to the home surface instead of the step,
where they hit the placeholder onboarding cards.
Repoint at the canonical `/setup/*` sequence instead (chat#1889):
- /onboarding/first-task -> /setup/tasks
- /onboarding/roster -> /setup/artists
- CatalogReportCta href -> /setup/tasks
The `/onboarding/*` mounts stay as redirects rather than being deleted so any
pasted or indexed link still resolves; deletion is a follow-up once logs show
no traffic.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/onboarding/first-task/page.tsx | 18 +++++------
app/onboarding/roster/page.tsx | 14 +++++++--
.../Catalog/report/CatalogReportCta.tsx | 7 ++++-
.../__tests__/CatalogReportCta.test.tsx | 31 +++++++++++++++++++
4 files changed, 56 insertions(+), 14 deletions(-)
create mode 100644 components/Catalog/report/__tests__/CatalogReportCta.test.tsx
diff --git a/app/onboarding/first-task/page.tsx b/app/onboarding/first-task/page.tsx
index f86f9fc59..b8286286e 100644
--- a/app/onboarding/first-task/page.tsx
+++ b/app/onboarding/first-task/page.tsx
@@ -1,20 +1,16 @@
import { redirect } from "next/navigation";
/**
- * Funnel-entry convergence (chat#1885): marketing-funnel signups used to
- * deep-link to this standalone first-task mount, a surface distinct from the
- * unified `OnboardingSequence` that direct-chat signups see. Route the funnel
- * entry through the same surface by sending it to the home gate, which renders
- * `OnboardingSequence` at the account's DERIVED step (`useOnboardingGate` →
- * `HomePage`) — so both entry paths converge on one onboarding experience and
- * the funnel CTA always lands at the correct step, never a hard-coded one.
+ * Retired into `/setup/*` (chat#1889): `/setup` is the canonical onboarding
+ * sequence, so this interim standalone mount forwards to the matching setup
+ * route instead of rendering a parallel flow.
*
- * Deferred (see PR): fully retire the interim standalone `/onboarding/*`
- * mounts (this route + `/onboarding/roster`) and repoint any external
- * deep-links once nothing references them.
+ * Kept as a redirect rather than deleted — the catalog report's primary CTA
+ * (the marketing funnel's and the valuation email's landing page) and any
+ * pasted/indexed link still resolve here. Delete once logs show no traffic.
*/
const FirstTaskOnboardingPage = () => {
- redirect("/");
+ redirect("/setup/tasks");
};
export default FirstTaskOnboardingPage;
diff --git a/app/onboarding/roster/page.tsx b/app/onboarding/roster/page.tsx
index 93d2abfc0..744de600a 100644
--- a/app/onboarding/roster/page.tsx
+++ b/app/onboarding/roster/page.tsx
@@ -1,5 +1,15 @@
-import RosterSocialsFlow from "@/components/Onboarding/RosterSocialsFlow";
+import { redirect } from "next/navigation";
-const OnboardingRoster = () => ;
+/**
+ * Retired into `/setup/*` (chat#1889): `/setup/artists` is the canonical
+ * roster step, so this interim standalone mount forwards there instead of
+ * rendering a second copy of `RosterSocialsFlow`.
+ *
+ * Kept as a redirect rather than deleted so any pasted/indexed link still
+ * resolves. Delete once logs show no traffic.
+ */
+const OnboardingRoster = () => {
+ redirect("/setup/artists");
+};
export default OnboardingRoster;
diff --git a/components/Catalog/report/CatalogReportCta.tsx b/components/Catalog/report/CatalogReportCta.tsx
index ab7d05f26..79e59bae9 100644
--- a/components/Catalog/report/CatalogReportCta.tsx
+++ b/components/Catalog/report/CatalogReportCta.tsx
@@ -5,6 +5,11 @@ import Link from "next/link";
* this valuation measured (onboarding sequence advance, chat#1867). Routes to
* the one-click first-task step — pre-runs the first weekly report, then
* confirms the Monday schedule — instead of a blank /tasks page.
+ *
+ * Points at the canonical `/setup/tasks` route rather than the retired
+ * `/onboarding/first-task` mount (chat#1889). This CTA is the landing page for
+ * both the marketing valuation funnel and the valuation email, so it must not
+ * route through a surface that is being retired.
*/
const CatalogReportCta = () => {
return (
@@ -20,7 +25,7 @@ const CatalogReportCta = () => {
re-measures your catalog and emails you the trend.
Set up your weekly report
diff --git a/components/Catalog/report/__tests__/CatalogReportCta.test.tsx b/components/Catalog/report/__tests__/CatalogReportCta.test.tsx
new file mode 100644
index 000000000..643900e05
--- /dev/null
+++ b/components/Catalog/report/__tests__/CatalogReportCta.test.tsx
@@ -0,0 +1,31 @@
+// @vitest-environment jsdom
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import CatalogReportCta from "@/components/Catalog/report/CatalogReportCta";
+
+vi.mock("next/link", () => ({
+ default: ({
+ href,
+ children,
+ ...rest
+ }: React.ComponentProps<"a"> & { href: string }) => (
+
+ {children}
+
+ ),
+}));
+
+describe("CatalogReportCta", () => {
+ it("routes to the canonical /setup/tasks step, not a retired /onboarding mount", () => {
+ render();
+ const cta = screen.getByRole("link", { name: /set up your weekly report/i });
+
+ // This CTA is the landing page for both the marketing valuation funnel and
+ // the valuation email, so it must point at the canonical setup sequence
+ // (chat#1889) — never at `/onboarding/*`, and never at `/`, which would
+ // drop a funnel signup on the home surface instead of the step.
+ expect(cta.getAttribute("href")).toBe("/setup/tasks");
+ expect(cta.getAttribute("href")).not.toContain("/onboarding");
+ });
+});
From df302111d29e791fae110a784ac5d4e45bd2cb05 Mon Sep 17 00:00:00 2001
From: Sweets Sweetman
Date: Mon, 27 Jul 2026 11:31:48 -0500
Subject: [PATCH 3/3] refactor(onboarding): retire /onboarding/* via config
redirects, delete the page stubs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The interim /onboarding/first-task and /onboarding/roster mounts were
scaffolding from chat#1880 so each step was user-testable before the
sequence container existed. Replace the redirect() page components with
308s in next.config.mjs and delete the files.
Two reasons over redirect() pages:
- Next prerenders a page whose body is just redirect(), so the old URL
answers HTTP 200 with the redirect in its RSC payload and only moves
once React hydrates. A crawler, link previewer or curl sees a 200 and
an empty shell. Config redirects are real 308s at the edge, checked
before the filesystem.
- No page files left behind carrying a 'delete once logs show no
traffic' marker. chat#1889 exists because interim surfaces accumulated
and were never retired; this does not add two more.
After this PR nothing internal references /onboarding/* — the only
referrer was CatalogReportCta, repointed here at /setup/tasks.
Co-Authored-By: Claude Opus 5 (1M context)
---
__tests__/nextConfigRedirects.test.ts | 58 +++++++++++++++++++
app/onboarding/first-task/page.tsx | 16 -----
app/onboarding/roster/page.tsx | 15 -----
app/setup/tasks/page.tsx | 2 +-
.../Catalog/report/CatalogReportCta.tsx | 9 +--
components/Onboarding/RosterSocialsFlow.tsx | 8 +--
next.config.mjs | 23 ++++++++
7 files changed, 91 insertions(+), 40 deletions(-)
create mode 100644 __tests__/nextConfigRedirects.test.ts
delete mode 100644 app/onboarding/first-task/page.tsx
delete mode 100644 app/onboarding/roster/page.tsx
diff --git a/__tests__/nextConfigRedirects.test.ts b/__tests__/nextConfigRedirects.test.ts
new file mode 100644
index 000000000..7632986a5
--- /dev/null
+++ b/__tests__/nextConfigRedirects.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import nextConfig from "@/next.config.mjs";
+
+/**
+ * The interim `/onboarding/*` mounts (chat#1880) were dev scaffolding for
+ * testing the steps before the sequence container existed. They are retired
+ * into the canonical `/setup/*` sequence (chat#1889).
+ *
+ * These live in `redirects()` rather than as `redirect()` page components so
+ * they resolve as real 308s at the edge — a prerendered page shipping the
+ * redirect in its RSC payload returns HTTP 200 to any non-JS client (crawler,
+ * link previewer, curl), which defeats the point of keeping the old URL alive.
+ */
+describe("next.config redirects", () => {
+ const getRedirects = async () => {
+ const config = nextConfig as { redirects?: () => Promise };
+ expect(typeof config.redirects).toBe("function");
+ return (await config.redirects!()) as Array<{
+ source: string;
+ destination: string;
+ permanent: boolean;
+ }>;
+ };
+
+ it("retires /onboarding/first-task into /setup/tasks", async () => {
+ const rule = (await getRedirects()).find(
+ (r) => r.source === "/onboarding/first-task",
+ );
+
+ expect(rule).toBeDefined();
+ expect(rule?.destination).toBe("/setup/tasks");
+ expect(rule?.permanent).toBe(true);
+ });
+
+ it("retires /onboarding/roster into /setup/artists", async () => {
+ const rule = (await getRedirects()).find(
+ (r) => r.source === "/onboarding/roster",
+ );
+
+ expect(rule).toBeDefined();
+ expect(rule?.destination).toBe("/setup/artists");
+ expect(rule?.permanent).toBe(true);
+ });
+
+ it("never sends a retired onboarding mount to the home surface", async () => {
+ // Home renders the placeholder step cards while onboarding is incomplete,
+ // so routing a funnel arrival there drops them out of the sequence.
+ const onboardingRules = (await getRedirects()).filter((r) =>
+ r.source.startsWith("/onboarding"),
+ );
+
+ expect(onboardingRules.length).toBeGreaterThan(0);
+ for (const rule of onboardingRules) {
+ expect(rule.destination).not.toBe("/");
+ expect(rule.destination).toMatch(/^\/setup\//);
+ }
+ });
+});
diff --git a/app/onboarding/first-task/page.tsx b/app/onboarding/first-task/page.tsx
deleted file mode 100644
index b8286286e..000000000
--- a/app/onboarding/first-task/page.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { redirect } from "next/navigation";
-
-/**
- * Retired into `/setup/*` (chat#1889): `/setup` is the canonical onboarding
- * sequence, so this interim standalone mount forwards to the matching setup
- * route instead of rendering a parallel flow.
- *
- * Kept as a redirect rather than deleted — the catalog report's primary CTA
- * (the marketing funnel's and the valuation email's landing page) and any
- * pasted/indexed link still resolve here. Delete once logs show no traffic.
- */
-const FirstTaskOnboardingPage = () => {
- redirect("/setup/tasks");
-};
-
-export default FirstTaskOnboardingPage;
diff --git a/app/onboarding/roster/page.tsx b/app/onboarding/roster/page.tsx
deleted file mode 100644
index 744de600a..000000000
--- a/app/onboarding/roster/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { redirect } from "next/navigation";
-
-/**
- * Retired into `/setup/*` (chat#1889): `/setup/artists` is the canonical
- * roster step, so this interim standalone mount forwards there instead of
- * rendering a second copy of `RosterSocialsFlow`.
- *
- * Kept as a redirect rather than deleted so any pasted/indexed link still
- * resolves. Delete once logs show no traffic.
- */
-const OnboardingRoster = () => {
- redirect("/setup/artists");
-};
-
-export default OnboardingRoster;
diff --git a/app/setup/tasks/page.tsx b/app/setup/tasks/page.tsx
index a2a6e4a31..4722438bf 100644
--- a/app/setup/tasks/page.tsx
+++ b/app/setup/tasks/page.tsx
@@ -2,7 +2,7 @@ import FirstTaskStep from "@/components/Onboarding/FirstTaskStep";
/**
* `/setup/tasks` — welcome email step 5 ("Automate with tasks"). Mounts the
- * self-contained first-task step (same as /onboarding/first-task).
+ * self-contained first-task step.
*/
const SetupTasksPage = () => ;
diff --git a/components/Catalog/report/CatalogReportCta.tsx b/components/Catalog/report/CatalogReportCta.tsx
index 79e59bae9..1a71d22f0 100644
--- a/components/Catalog/report/CatalogReportCta.tsx
+++ b/components/Catalog/report/CatalogReportCta.tsx
@@ -6,10 +6,11 @@ import Link from "next/link";
* the one-click first-task step — pre-runs the first weekly report, then
* confirms the Monday schedule — instead of a blank /tasks page.
*
- * Points at the canonical `/setup/tasks` route rather than the retired
- * `/onboarding/first-task` mount (chat#1889). This CTA is the landing page for
- * both the marketing valuation funnel and the valuation email, so it must not
- * route through a surface that is being retired.
+ * Points at the canonical `/setup/tasks` route rather than the deleted
+ * `/onboarding/first-task` mount (chat#1889, now a 308 in `next.config.mjs`).
+ * This CTA is the landing page for both the marketing valuation funnel and the
+ * valuation email, so it must route straight at the canonical step rather than
+ * bouncing through a retired one.
*/
const CatalogReportCta = () => {
return (
diff --git a/components/Onboarding/RosterSocialsFlow.tsx b/components/Onboarding/RosterSocialsFlow.tsx
index 825decedd..366af3495 100644
--- a/components/Onboarding/RosterSocialsFlow.tsx
+++ b/components/Onboarding/RosterSocialsFlow.tsx
@@ -8,10 +8,10 @@ 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.
+ * Standalone container for the roster + socials onboarding steps, mounted at
+ * `/setup/artists` and `/setup/socials`. The sibling onboarding-sequence
+ * router (chat#1867) mounts `ConfirmRosterStep` and `VerifySocialsStep`
+ * directly with its own state-derived stepping.
*
* `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
diff --git a/next.config.mjs b/next.config.mjs
index c5387a5b5..0ed00b309 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -7,6 +7,29 @@ const nextConfig = {
reactStrictMode: true,
transpilePackages: ["geist"],
serverExternalPackages: ['@browserbasehq/stagehand', 'playwright'],
+ // The interim `/onboarding/*` mounts (chat#1880) were scaffolding so each
+ // step was user-testable before the sequence container existed. `/setup/*`
+ // is now the canonical sequence (chat#1889), so they are retired here.
+ //
+ // Config redirects rather than `redirect()` page components: Next prerenders
+ // such a page and ships the redirect in its RSC payload, so the old URL
+ // answers HTTP 200 to any non-JS client (crawler, link previewer, curl) and
+ // only redirects once React hydrates. These are real 308s at the edge, and
+ // they leave no page files behind to forget to delete.
+ redirects() {
+ return [
+ {
+ source: "/onboarding/first-task",
+ destination: "/setup/tasks",
+ permanent: true,
+ },
+ {
+ source: "/onboarding/roster",
+ destination: "/setup/artists",
+ permanent: true,
+ },
+ ];
+ },
experimental: {
optimizePackageImports: [
'lucide-react',