diff --git a/hooks/useAddSpotifyArtist.ts b/hooks/useAddSpotifyArtist.ts index 23e270d66..dd7be7974 100644 --- a/hooks/useAddSpotifyArtist.ts +++ b/hooks/useAddSpotifyArtist.ts @@ -2,21 +2,31 @@ import { useState } from "react"; import { usePrivy } from "@privy-io/react-auth"; +import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { addSpotifyArtist } from "@/lib/artists/addSpotifyArtist"; +import { runValuation } from "@/lib/valuation/runValuation"; import { useArtistProvider } from "@/providers/ArtistProvider"; import { useOrganization } from "@/providers/OrganizationProvider"; +import useCatalogs from "@/hooks/useCatalogs"; 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`. + * + * 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 — + * seeding the onboarding catalog in the background so the "Claim your catalog" + * step is already complete by the time the user reaches it (chat#1867). */ export function useAddSpotifyArtist() { const { getAccessToken } = usePrivy(); const { getArtists } = useArtistProvider(); const { selectedOrgId } = useOrganization(); + const catalogsQuery = useCatalogs(); + const queryClient = useQueryClient(); const [isAdding, setIsAdding] = useState(false); const add = async (artist: SpotifyArtistSearchResult): Promise => { @@ -33,6 +43,25 @@ export function useAddSpotifyArtist() { ); await getArtists(created.account_id); toast.success(`${artist.name} added to your roster`); + + // Seed the onboarding catalog from the first artist's discography. + // Only when we know there is no catalog yet (empty, not just unloaded), + // and fire-and-forget so the dialog closes immediately — the catalog + // materializes in ~20s and the sequence advances on the next landing. + const catalogs = catalogsQuery.data?.catalogs; + if (catalogs && catalogs.length === 0) { + toast.promise( + runValuation(accessToken, artist.id).then(() => { + queryClient.invalidateQueries({ queryKey: ["catalogs"] }); + }), + { + loading: `Valuing ${artist.name}'s catalog…`, + success: "Your catalog is ready", + error: + "Couldn't value the catalog automatically — you can claim it later", + }, + ); + } return true; } catch (error) { toast.error( diff --git a/lib/ai/__tests__/organizeModels.test.ts b/lib/ai/__tests__/organizeModels.test.ts index b14a5254a..240585fe1 100644 --- a/lib/ai/__tests__/organizeModels.test.ts +++ b/lib/ai/__tests__/organizeModels.test.ts @@ -10,9 +10,9 @@ describe("organizeModels", () => { it("splits featured and non-featured gateway models", () => { const models = [ { - id: "openai/gpt-5.2", - name: "gpt-5.2", - specification: { specificationVersion: "v2", provider: "openai", modelId: "gpt-5.2" }, + id: "openai/gpt-5.5", + name: "gpt-5.5", + specification: { specificationVersion: "v2", provider: "openai", modelId: "gpt-5.5" }, }, { id: "some/other-model", @@ -22,7 +22,7 @@ describe("organizeModels", () => { ] as never; const result = organizeModels(models); - expect(result.featuredModels.map((m) => m.id)).toContain("openai/gpt-5.2"); + expect(result.featuredModels.map((m) => m.id)).toContain("openai/gpt-5.5"); expect(result.otherModels.map((m) => m.id)).toEqual(["some/other-model"]); }); }); diff --git a/lib/valuation/__tests__/runValuation.test.ts b/lib/valuation/__tests__/runValuation.test.ts new file mode 100644 index 000000000..1a2fbfc2b --- /dev/null +++ b/lib/valuation/__tests__/runValuation.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { runValuation } from "@/lib/valuation/runValuation"; +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; + +vi.mock("@/lib/api/getClientApiBaseUrl", () => ({ + getClientApiBaseUrl: vi.fn(), +})); + +describe("runValuation", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getClientApiBaseUrl).mockReturnValue("https://api.example.com"); + }); + + it("POSTs the spotify_artist_id to /api/valuation with bearer auth", async () => { + global.fetch = vi + .fn() + .mockResolvedValue({ ok: true }) as unknown as typeof fetch; + + await runValuation("tok", "0xPoV"); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/api/valuation", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ Authorization: "Bearer tok" }), + body: JSON.stringify({ spotify_artist_id: "0xPoV" }), + }), + ); + }); + + it("throws on a non-ok response", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 402, + text: vi.fn().mockResolvedValue("insufficient credits"), + }) as unknown as typeof fetch; + + await expect(runValuation("tok", "0xPoV")).rejects.toThrow("402"); + }); +}); diff --git a/lib/valuation/runValuation.ts b/lib/valuation/runValuation.ts new file mode 100644 index 000000000..e0d108f18 --- /dev/null +++ b/lib/valuation/runValuation.ts @@ -0,0 +1,29 @@ +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; + +/** + * Kicks `POST /api/valuation` for a Spotify artist (api#776): resolves the + * artist's releases, measures play counts, and materializes an account-owned + * catalog + value band. Used fire-and-forget on the first artist add to seed + * the onboarding catalog so "Claim your catalog" is already complete. + * + * @param accessToken - Privy bearer for the owning account. + * @param spotifyArtistId - The Spotify artist id to value. + */ +export async function runValuation( + accessToken: string, + spotifyArtistId: string, +): Promise { + const res = await fetch(`${getClientApiBaseUrl()}/api/valuation`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ spotify_artist_id: spotifyArtistId }), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`Valuation failed: HTTP ${res.status} ${detail}`.trim()); + } +}