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 —