Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions app/setup/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <SetupEntry />;
}
32 changes: 16 additions & 16 deletions components/Home/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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 (
<div className="flex flex-col size-full items-center">
<OnboardingSequence
step={onboarding.step}
checkpoints={onboarding.checkpoints}
onSkip={onboarding.skip}
/>
</div>
);
}
// 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 =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an incomplete account lands on /, the app home now flashes briefly before redirecting to /setup. The old synchronous conditional return rendered the onboarding sequence inline, so there was no flash. The new useEffect-based redirect means the full home UI (chat bootstrap, empty content area) is painted at least once before the post-render effect fires. Consider restoring a synchronous guard — even a minimal loading shell returned early while isReady is false would eliminate the flash while keeping the redirect architecture intact.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/Home/HomePage.tsx, line 28:

<comment>When an incomplete account lands on `/`, the app home now flashes briefly before redirecting to `/setup`. The old synchronous conditional return rendered the onboarding sequence inline, so there was no flash. The new `useEffect`-based redirect means the full home UI (chat bootstrap, empty content area) is painted at least once before the post-render effect fires. Consider restoring a synchronous guard — even a minimal loading shell returned early while `isReady` is false would eliminate the flash while keeping the redirect architecture intact.</comment>

<file context>
@@ -18,21 +19,20 @@ const HomePage = ({ initialMessages }: { initialMessages?: UIMessage[] }) => {
+  // 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";
+
</file context>

onboarding.view === "sequence" && onboarding.step !== "complete";

useEffect(() => {
if (shouldEnterSetup) {
router.replace("/setup");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Incomplete accounts now mount the chat during the redirect window, so they can provision a recoup-api chat session/sandbox even though they are immediately sent to setup. Render a loading/empty setup-transition state while shouldEnterSetup is true so NewChatBootstrap does not mount.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/Home/HomePage.tsx, line 33:

<comment>Incomplete accounts now mount the chat during the redirect window, so they can provision a recoup-api chat session/sandbox even though they are immediately sent to setup. Render a loading/empty setup-transition state while `shouldEnterSetup` is true so `NewChatBootstrap` does not mount.</comment>

<file context>
@@ -18,21 +19,20 @@ const HomePage = ({ initialMessages }: { initialMessages?: UIMessage[] }) => {
+
+  useEffect(() => {
+    if (shouldEnterSetup) {
+      router.replace("/setup");
+    }
+  }, [shouldEnterSetup, router]);
</file context>

}
Comment on lines +31 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid provisioning chat before redirecting to setup

For every authenticated account whose onboarding is incomplete, this effect redirects only after HomePage has rendered NewChatBootstrap. That child immediately runs useProvisionChatSession, whose effect issues non-idempotent session and sandbox POSTs, so visiting home during onboarding creates an orphan chat/session/sandbox before navigation to /setup. Render a non-chat loading state while shouldEnterSetup is true, or otherwise gate bootstrap provisioning until the account is staying on home.

Useful? React with 👍 / 👎.

}, [shouldEnterSetup, router]);

return (
<div className="relative flex flex-col size-full items-center">
Expand Down
55 changes: 28 additions & 27 deletions components/Home/__tests__/HomePage.onboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}));
Expand All @@ -12,16 +15,8 @@ vi.mock("@/components/VercelChat/NewChatBootstrap", () => ({
default: () => <div data-testid="chat-bootstrap" />,
}));

vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: React.ComponentProps<"a"> & { href: string }) => (
<a href={href} {...rest}>
{children}
</a>
),
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace, push: vi.fn() }),
}));

vi.mock("@/providers/UserProvder", () => ({
Expand All @@ -41,37 +36,45 @@ 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(<HomePage />);
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(<HomePage />);
// 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(<HomePage />);
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 }),
).toBeDefined();
});

it("positions the checklist inside the content area, not fixed to the viewport under the right rail", () => {
window.sessionStorage.setItem(SKIP_KEY, "1");
render(<HomePage />);
fireEvent.click(screen.getByRole("button", { name: /skip for now/i }));
const checklist = screen.getByRole("complementary", {
name: /onboarding checklist/i,
});
Expand All @@ -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(<HomePage />);
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(<HomePage />);
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");
});
});
16 changes: 11 additions & 5 deletions components/Onboarding/FirstTaskStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -33,6 +37,7 @@ const FirstTaskStep = () => {

return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-8">
<SetupProgress step="task" />
<header>
<h1 className="text-xl font-semibold text-foreground">
Your first weekly report
Expand Down Expand Up @@ -84,6 +89,7 @@ const FirstTaskStep = () => {
catalogName={catalogName}
/>
)}
<SetupSkipLink />
</div>
);
};
Expand Down
10 changes: 6 additions & 4 deletions components/Onboarding/OnboardingCheckpointList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -35,7 +37,7 @@ const OnboardingCheckpointList = ({
: "text-foreground",
)}
>
{getOnboardingStepContent(checkpoint.id).title}
{getOnboardingStepTitle(checkpoint.id)}
</span>
<span className="sr-only">
{checkpoint.complete ? "(complete)" : "(incomplete)"}
Expand Down
51 changes: 0 additions & 51 deletions components/Onboarding/OnboardingSequence.tsx

This file was deleted.

39 changes: 0 additions & 39 deletions components/Onboarding/OnboardingStepCard.tsx

This file was deleted.

21 changes: 11 additions & 10 deletions components/Onboarding/RosterSocialsFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -25,11 +27,9 @@ const RosterSocialsFlow = ({
const [step, setStep] = useState<FlowStep>(initialStep);

return (
<div className="w-full max-w-xl mx-auto grow py-8 px-6">
<div className="w-full max-w-xl mx-auto grow py-8 px-6 flex flex-col gap-6">
{step !== "done" && (
<p className="text-xs text-muted-foreground mb-6">
Step {step === "roster" ? "1" : "2"} of 2
</p>
<SetupProgress step={step === "roster" ? "artists" : "socials"} />
)}
{step === "roster" && (
<ConfirmRosterStep onConfirmed={() => setStep("socials")} />
Expand All @@ -38,6 +38,7 @@ const RosterSocialsFlow = ({
<VerifySocialsStep onConfirmed={() => setStep("done")} />
)}
{step === "done" && <RosterVerifiedPanel />}
{step !== "done" && <SetupSkipLink />}
</div>
);
};
Expand Down
Loading
Loading