diff --git a/components/Onboarding/AddArtistForm.tsx b/components/Onboarding/AddArtistForm.tsx
index 67529459f..dc66575fa 100644
--- a/components/Onboarding/AddArtistForm.tsx
+++ b/components/Onboarding/AddArtistForm.tsx
@@ -4,7 +4,7 @@ import { useState } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import SpotifyArtistSearch from "@/components/Artists/SpotifyArtistSearch";
-import { useAddRosterArtist } from "@/hooks/onboarding/useAddRosterArtist";
+import { useAddSpotifyArtist } from "@/hooks/useAddSpotifyArtist";
import type { SpotifyArtistSearchResult } from "@/types/spotify";
/**
@@ -15,16 +15,19 @@ import type { SpotifyArtistSearchResult } from "@/types/spotify";
* structurally unreachable for anyone who arrived without a funnel valuation
* (chat#1889). Picking a real Spotify artist resolves the id, profile URL, and
* avatar in one step, reusing the same typeahead as verify-socials (chat#1882).
+ *
+ * Adds through `useAddSpotifyArtist` — the same hook the `/artists` dialog uses
+ * — because it also kicks `POST /api/valuation` for the picked artist when the
+ * account has no catalog yet. Without that seeding an account onboarded here
+ * finished setup with nothing to value, so the reward on the completion panel
+ * could only ever render its "Claim your catalog" fallback (chat#1889 row 8).
*/
const AddArtistForm = () => {
- const { addArtist, isAdding } = useAddRosterArtist();
+ const { add, isAdding } = useAddSpotifyArtist();
const [isOpen, setIsOpen] = useState(false);
const handleSelect = async (artist: SpotifyArtistSearchResult) => {
- const added = await addArtist(artist.name, {
- profileUrl: artist.external_urls.spotify,
- image: artist.images?.[0]?.url,
- });
+ const added = await add(artist);
if (added) setIsOpen(false);
};
diff --git a/components/Onboarding/MeasuringCatalogPanel.tsx b/components/Onboarding/MeasuringCatalogPanel.tsx
new file mode 100644
index 000000000..40f2325aa
--- /dev/null
+++ b/components/Onboarding/MeasuringCatalogPanel.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import Link from "next/link";
+import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+/**
+ * Terminal state for `/setup/valuation` while a catalog exists but has no
+ * valuation yet.
+ *
+ * Seeding (chat#1889 row 8) creates the catalog seconds after the first artist
+ * is added and its measurements land later, so this window is routine. Without
+ * a state of its own the route could neither redirect (there IS a catalog) nor
+ * render the hero, and sat on a skeleton with no exit.
+ */
+const MeasuringCatalogPanel = () => (
+
+
+ Measuring your catalog
+
+
+ We are pulling play counts for every track. This usually takes a minute.
+ Your baseline value appears here as soon as it is ready.
+
+
+ Set up your weekly report
+
+
+);
+
+export default MeasuringCatalogPanel;
diff --git a/components/Onboarding/SetupValuation.tsx b/components/Onboarding/SetupValuation.tsx
index ba6626019..ba982fb2a 100644
--- a/components/Onboarding/SetupValuation.tsx
+++ b/components/Onboarding/SetupValuation.tsx
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { buttonVariants } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import ValuationHero from "@/components/Home/ValuationHero";
+import MeasuringCatalogPanel from "@/components/Onboarding/MeasuringCatalogPanel";
import useCatalogs from "@/hooks/useCatalogs";
import useHomeValuation from "@/hooks/useHomeValuation";
import { cn } from "@/lib/utils";
@@ -36,7 +37,7 @@ const SetupValuation = () => {
if (!hasCatalog || isError) router.replace("/catalogs");
}, [isPending, hasCatalog, isError, router]);
- if (isPending || !valuation.show) {
+ if (isPending) {
return (
@@ -45,6 +46,10 @@ const SetupValuation = () => {
);
}
+ // A catalog exists but has no valuation yet: routine while a seeded
+ // valuation is still measuring (chat#1889 row 8).
+ if (!valuation.show) return ;
+
return (
({
- useAddRosterArtist: () => ({ addArtist, isAdding: false }),
+vi.mock("@/hooks/useAddSpotifyArtist", () => ({
+ useAddSpotifyArtist: () => ({ add, isAdding: false }),
}));
vi.mock("@/hooks/useSpotifyArtistSearch", () => ({
@@ -27,8 +29,8 @@ vi.mock("@/hooks/useSpotifyArtistSearch", () => ({
describe("AddArtistForm", () => {
beforeEach(() => {
- addArtist.mockClear();
- addArtist.mockResolvedValue(true);
+ add.mockClear();
+ add.mockResolvedValue(true);
});
const openForm = () => {
@@ -46,13 +48,15 @@ describe("AddArtistForm", () => {
expect(screen.getByLabelText(/search spotify for an artist/i)).toBeDefined();
});
- it("resolves the picked artist to its Spotify profile URL and avatar", () => {
+ // useAddSpotifyArtist seeds the onboarding catalog by kicking POST
+ // /api/valuation for the picked artist when the account has none yet.
+ // useAddRosterArtist never did, so an account onboarded here finished setup
+ // with no catalog and the valuation reward could only show its fallback
+ // (chat#1889 row 8).
+ it("adds through the hook that seeds the catalog, passing the whole Spotify result", () => {
openForm();
fireEvent.click(screen.getByRole("option", { name: /drake/i }));
- expect(addArtist).toHaveBeenCalledWith("Drake", {
- profileUrl: SPOTIFY_RESULT.external_urls.spotify,
- image: SPOTIFY_RESULT.images[0].url,
- });
+ expect(add).toHaveBeenCalledWith(SPOTIFY_RESULT);
});
});
diff --git a/components/Onboarding/__tests__/SetupValuation.test.tsx b/components/Onboarding/__tests__/SetupValuation.test.tsx
index 757cd71dd..ec2c9e0de 100644
--- a/components/Onboarding/__tests__/SetupValuation.test.tsx
+++ b/components/Onboarding/__tests__/SetupValuation.test.tsx
@@ -94,4 +94,23 @@ describe("SetupValuation", () => {
expect(getByText("hero")).toBeDefined();
expect(replace).not.toHaveBeenCalled();
});
+ // Seeding (chat#1889 row 8) creates the catalog seconds after the first
+ // artist is added, but its measurements land later — so this window is now
+ // routine, not theoretical. Without a terminal state the route renders a
+ // skeleton forever: the redirect cannot fire (there IS a catalog) and the
+ // hero cannot render (nothing measured yet).
+ it("shows a measuring state, not an endless skeleton, when the catalog has no valuation yet", () => {
+ catalogsResult = {
+ data: { catalogs: [{ id: "cat-1" }] },
+ isLoading: false,
+ isPending: false,
+ isError: false,
+ };
+ valuationResult = { show: false };
+
+ const { getByText } = render();
+
+ expect(getByText(/measuring your catalog/i)).toBeDefined();
+ expect(replace).not.toHaveBeenCalled();
+ });
});
diff --git a/hooks/onboarding/__tests__/useAddRosterArtist.test.tsx b/hooks/onboarding/__tests__/useAddRosterArtist.test.tsx
deleted file mode 100644
index 4919802b4..000000000
--- a/hooks/onboarding/__tests__/useAddRosterArtist.test.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-// @vitest-environment jsdom
-import { act, renderHook } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { useAddRosterArtist } from "@/hooks/onboarding/useAddRosterArtist";
-
-const createRosterArtist = vi.fn();
-const saveArtist = vi.fn();
-const getArtists = vi.fn();
-const toastError = vi.fn();
-
-vi.mock("@privy-io/react-auth", () => ({
- usePrivy: () => ({ getAccessToken: async () => "token" }),
-}));
-
-vi.mock("@/lib/artists/createRosterArtist", () => ({
- createRosterArtist: (...args: unknown[]) => createRosterArtist(...args),
-}));
-
-vi.mock("@/lib/saveArtist", () => ({
- default: (...args: unknown[]) => saveArtist(...args),
-}));
-
-vi.mock("@/providers/ArtistProvider", () => ({
- useArtistProvider: () => ({ getArtists }),
-}));
-
-vi.mock("@/providers/OrganizationProvider", () => ({
- useOrganization: () => ({ selectedOrgId: "org-1" }),
-}));
-
-vi.mock("sonner", () => ({
- toast: { error: (...args: unknown[]) => toastError(...args) },
-}));
-
-const SPOTIFY = {
- profileUrl: "https://open.spotify.com/artist/43qxAkuKFB6fMNSeS5dO7Z",
- image: "https://i.scdn.co/image/ana.jpg",
-};
-
-describe("useAddRosterArtist", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- createRosterArtist.mockResolvedValue({ artist: { account_id: "artist-1" }, created: true });
- saveArtist.mockResolvedValue(undefined);
- });
-
- it("enriches a created artist with its Spotify profile and avatar", async () => {
- const { result } = renderHook(() => useAddRosterArtist());
-
- let added: boolean | undefined;
- await act(async () => {
- added = await result.current.addArtist("Ana Bárbara", SPOTIFY);
- });
-
- expect(added).toBe(true);
- expect(saveArtist).toHaveBeenCalledWith("token", "artist-1", {
- profileUrls: { SPOTIFY: SPOTIFY.profileUrl },
- image: SPOTIFY.image,
- });
- expect(getArtists).toHaveBeenCalled();
- });
-
- // POST /api/artists has already succeeded by the time enrichment runs, so a
- // failing PATCH must not report failure: the form would stay open over an
- // artist that exists, and picking again creates a duplicate (chat#1889).
- it("treats a failed enrichment as non-fatal", async () => {
- saveArtist.mockRejectedValue(new Error("Forbidden"));
- const { result } = renderHook(() => useAddRosterArtist());
-
- let added: boolean | undefined;
- await act(async () => {
- added = await result.current.addArtist("Ana Bárbara", SPOTIFY);
- });
-
- expect(added).toBe(true);
- expect(getArtists).toHaveBeenCalled();
- expect(toastError).not.toHaveBeenCalled();
- });
-
- it("still reports failure when the artist itself cannot be created", async () => {
- createRosterArtist.mockRejectedValue(new Error("nope"));
- const { result } = renderHook(() => useAddRosterArtist());
-
- let added: boolean | undefined;
- await act(async () => {
- added = await result.current.addArtist("Ana Bárbara", SPOTIFY);
- });
-
- expect(added).toBe(false);
- expect(toastError).toHaveBeenCalled();
- expect(getArtists).not.toHaveBeenCalled();
- });
-});
diff --git a/hooks/onboarding/useAddRosterArtist.ts b/hooks/onboarding/useAddRosterArtist.ts
deleted file mode 100644
index d92a18c89..000000000
--- a/hooks/onboarding/useAddRosterArtist.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { usePrivy } from "@privy-io/react-auth";
-import { toast } from "sonner";
-import { createRosterArtist } from "@/lib/artists/createRosterArtist";
-import saveArtist from "@/lib/saveArtist";
-import { buildSocialFixPayload } from "@/lib/onboarding/buildSocialFixPayload";
-import { useArtistProvider } from "@/providers/ArtistProvider";
-import { useOrganization } from "@/providers/OrganizationProvider";
-
-export interface AddRosterArtistOptions {
- /** Spotify profile URL from the typeahead, linked as the artist's social. */
- profileUrl?: string;
- /** Spotify avatar, so the roster row isn't a blank initial. */
- image?: string;
-}
-
-/**
- * "Add an artist" for the onboarding roster step. Creates the artist through
- * `POST /api/artists`, then — when the caller resolved a real Spotify artist —
- * attaches the profile URL and avatar via the existing
- * `PATCH /api/artists/{id}` path, and refreshes the shared roster.
- *
- * The second call is what makes the added artist usable: `POST /api/artists`
- * accepts only a name, and an artist with no Spotify id can never get a catalog
- * or a valuation (chat#1889). Enrichment failing is non-fatal — the artist is
- * already on the roster and its socials can still be fixed in the next step.
- */
-export function useAddRosterArtist() {
- const { getAccessToken } = usePrivy();
- const { getArtists } = useArtistProvider();
- const { selectedOrgId } = useOrganization();
- const [isAdding, setIsAdding] = useState(false);
-
- const addArtist = async (
- name: string,
- options: AddRosterArtistOptions = {},
- ): Promise => {
- const trimmed = name.trim();
- if (!trimmed) return false;
- setIsAdding(true);
- try {
- const accessToken = await getAccessToken();
- if (!accessToken) {
- throw new Error("Please sign in to add an artist");
- }
- const { artist } = await createRosterArtist(
- accessToken,
- trimmed,
- selectedOrgId,
- );
-
- const payload = options.profileUrl
- ? buildSocialFixPayload(options.profileUrl)
- : null;
- if (artist?.account_id && (payload || options.image)) {
- try {
- await saveArtist(accessToken, artist.account_id, {
- ...(payload ? { profileUrls: payload.profileUrls } : {}),
- ...(options.image ? { image: options.image } : {}),
- });
- } catch {
- // Swallowed on purpose. POST /api/artists already succeeded, so
- // surfacing this would leave the form open over an artist that
- // exists — and picking again would create a duplicate. The socials
- // are still fixable in the next step.
- }
- }
-
- await getArtists();
- return true;
- } catch (error) {
- toast.error(
- error instanceof Error ? error.message : "Failed to add artist",
- );
- return false;
- } finally {
- setIsAdding(false);
- }
- };
-
- return { addArtist, isAdding };
-}
diff --git a/hooks/useAddSpotifyArtist.ts b/hooks/useAddSpotifyArtist.ts
index dd7be7974..0631f2d2a 100644
--- a/hooks/useAddSpotifyArtist.ts
+++ b/hooks/useAddSpotifyArtist.ts
@@ -13,8 +13,9 @@ import type { SpotifyArtistSearchResult } from "@/types/spotify";
/**
* Adds a Spotify-searched artist to the roster and refreshes + selects it.
- * Wraps `addSpotifyArtist` with auth + the shared roster (ArtistProvider),
- * mirroring `useAddRosterArtist`.
+ * Wraps `addSpotifyArtist` with auth + the shared roster (ArtistProvider).
+ * The onboarding roster step adds through this hook too, so a first artist
+ * added during setup seeds the catalog the same way (chat#1889 row 8).
*
* On the **first** artist add (the account has no catalog yet) it also
* fire-and-forget kicks `POST /api/valuation` for that artist's Spotify id —