-
Notifications
You must be signed in to change notification settings - Fork 19
feat(onboarding): add artists by Spotify typeahead, not free text (chat#1889 row 8) #1892
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,24 +1,31 @@ | ||
| "use client"; | ||
|
|
||
| import { useState } from "react"; | ||
| import { Loader, Plus } from "lucide-react"; | ||
| import { Plus } from "lucide-react"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { Input } from "@/components/ui/input"; | ||
| import SpotifyArtistSearch from "@/components/Artists/SpotifyArtistSearch"; | ||
| import { useAddRosterArtist } from "@/hooks/onboarding/useAddRosterArtist"; | ||
| import type { SpotifyArtistSearchResult } from "@/types/spotify"; | ||
|
|
||
| /** Inline "add another artist" form for multi-artist managers. */ | ||
| /** | ||
| * "Add an artist" for the onboarding roster step, by Spotify typeahead. | ||
| * | ||
| * It used to take a free-text name, which produced an artist with no Spotify id | ||
| * — so no catalog and therefore no valuation could follow, and the payoff was | ||
| * 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). | ||
| */ | ||
| const AddArtistForm = () => { | ||
| const { addArtist, isAdding } = useAddRosterArtist(); | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [name, setName] = useState(""); | ||
|
|
||
| const handleSubmit = async (e: React.FormEvent) => { | ||
| e.preventDefault(); | ||
| const added = await addArtist(name); | ||
| if (added) { | ||
| setName(""); | ||
| setIsOpen(false); | ||
| } | ||
| const handleSelect = async (artist: SpotifyArtistSearchResult) => { | ||
| const added = await addArtist(artist.name, { | ||
| profileUrl: artist.external_urls.spotify, | ||
| image: artist.images?.[0]?.url, | ||
| }); | ||
|
Comment on lines
+24
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a cold-start account with no catalogs, this selection passes only the artist name, profile URL, and image, discarding Useful? React with 👍 / 👎. |
||
| if (added) setIsOpen(false); | ||
| }; | ||
|
|
||
| if (!isOpen) { | ||
|
|
@@ -36,27 +43,23 @@ const AddArtistForm = () => { | |
| } | ||
|
|
||
| return ( | ||
| <form onSubmit={handleSubmit} className="flex items-center gap-2"> | ||
| <Input | ||
| <div className="flex flex-col gap-2"> | ||
| <SpotifyArtistSearch | ||
| autoFocus | ||
| value={name} | ||
| onChange={(e) => setName(e.target.value)} | ||
| placeholder="Artist name" | ||
| disabled={isAdding} | ||
| aria-label="Artist name" | ||
| isBusy={isAdding} | ||
| onSelect={(artist) => void handleSelect(artist)} | ||
| /> | ||
| <Button type="submit" disabled={isAdding || !name.trim()}> | ||
| {isAdding ? <Loader className="size-4 animate-spin" /> : "Add"} | ||
| </Button> | ||
| <Button | ||
| type="button" | ||
| variant="ghost" | ||
| size="sm" | ||
| className="self-start text-muted-foreground" | ||
| disabled={isAdding} | ||
| onClick={() => setIsOpen(false)} | ||
| > | ||
| Cancel | ||
| </Button> | ||
| </form> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,8 @@ | |
| import { Button } from "@/components/ui/button"; | ||
| import { Skeleton } from "@/components/ui/skeleton"; | ||
| import { useArtistProvider } from "@/providers/ArtistProvider"; | ||
| import useCatalogs from "@/hooks/useCatalogs"; | ||
| import { getConfirmRosterCopy } from "@/lib/onboarding/getConfirmRosterCopy"; | ||
| import AddArtistForm from "./AddArtistForm"; | ||
| import RosterArtistRow from "./RosterArtistRow"; | ||
|
|
||
|
|
@@ -13,7 +15,9 @@ import RosterArtistRow from "./RosterArtistRow"; | |
| */ | ||
| const ConfirmRosterStep = ({ onConfirmed }: { onConfirmed: () => void }) => { | ||
| const { sorted, isLoading } = useArtistProvider(); | ||
| const { data: catalogsData } = useCatalogs(); | ||
| const artists = sorted.filter((artist) => !artist.isWorkspace); | ||
| const hasValuation = (catalogsData?.catalogs?.length ?? 0) > 0; | ||
|
Comment on lines
+18
to
+20
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== files =="
fd -a 'ConfirmRosterStep\.tsx|.*catalogs.*|useCatalogs.*' . | sed 's#^\./##' | head -100
echo
echo "== ConfirmRosterStep outline =="
if [ -f components/Onboarding/ConfirmRosterStep.tsx ]; then
wc -l components/Onboarding/ConfirmRosterStep.tsx
ast-grep outline components/Onboarding/ConfirmRosterStep.tsx || true
echo
cat -n components/Onboarding/ConfirmRosterStep.tsx | sed -n '1,260p'
fi
echo
echo "== useCatalogs definitions/usages =="
rg -n "useCatalogs|type .*Catalog|interface .*Catalog|catalogsData|hasValuation" -S .Repository: recoupable/chat Length of output: 10705 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== useCatalogs.ts =="
cat -n hooks/useCatalogs.ts | sed -n '1,120p'
echo
echo "== ConfirmRosterCopy =="
cat -n lib/onboarding/getConfirmRosterCopy.ts | sed -n '1,120p'
echo
echo "== Query behavior from source =="
rg -n "UseQueryResult|UseSuspense|staleTime|initialData|suspend|error\\b|isLoading\\b|isFetched\\b|refetchOnMount" -S hooks/lib components | head -200Repository: recoupable/chat Length of output: 21063 Preserve the unresolved catalog state.
🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <section className="flex flex-col gap-6"> | ||
|
|
@@ -22,9 +26,10 @@ const ConfirmRosterStep = ({ onConfirmed }: { onConfirmed: () => void }) => { | |
| Confirm your roster | ||
| </h1> | ||
| <p className="text-sm text-muted-foreground mt-1"> | ||
| We set {artists.length === 1 ? "this artist" : "these artists"} up | ||
| from your valuation. Add anyone else you manage — you can verify their | ||
| socials next. | ||
| {getConfirmRosterCopy({ | ||
| artistCount: artists.length, | ||
| hasValuation, | ||
| })} | ||
| </p> | ||
| </div> | ||
|
|
||
|
|
@@ -36,7 +41,8 @@ const ConfirmRosterStep = ({ onConfirmed }: { onConfirmed: () => void }) => { | |
| </> | ||
| ) : artists.length === 0 ? ( | ||
| <p className="text-sm text-muted-foreground p-4 rounded-xl border border-border"> | ||
| No artists on your roster yet. Add your first artist below. | ||
| No artists on your roster yet. Add your first artist below to get | ||
| your catalog valued. | ||
| </p> | ||
| ) : ( | ||
| artists.map((artist) => ( | ||
|
|
@@ -52,7 +58,7 @@ const ConfirmRosterStep = ({ onConfirmed }: { onConfirmed: () => void }) => { | |
| disabled={isLoading || artists.length === 0} | ||
| onClick={onConfirmed} | ||
| > | ||
| {artists.length > 1 ? "These are my artists" : "This is my artist"} — | ||
| {artists.length > 1 ? "These are my artists" : "This is my artist"}, | ||
| continue | ||
| </Button> | ||
| </section> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| // @vitest-environment jsdom | ||
| import React from "react"; | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import AddArtistForm from "@/components/Onboarding/AddArtistForm"; | ||
|
|
||
| const addArtist = vi.fn(); | ||
|
|
||
| const SPOTIFY_RESULT = { | ||
| id: "3TVXtAsR1Inumwj472S9r4", | ||
| name: "Drake", | ||
| external_urls: { spotify: "https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4" }, | ||
| images: [{ url: "https://i.scdn.co/image/drake.jpg" }], | ||
| followers: { total: 92000000 }, | ||
| }; | ||
|
|
||
| vi.mock("@/hooks/onboarding/useAddRosterArtist", () => ({ | ||
| useAddRosterArtist: () => ({ addArtist, isAdding: false }), | ||
| })); | ||
|
|
||
| vi.mock("@/hooks/useSpotifyArtistSearch", () => ({ | ||
| useSpotifyArtistSearch: () => ({ | ||
| results: [SPOTIFY_RESULT], | ||
| isSearching: false, | ||
| }), | ||
| })); | ||
|
|
||
| describe("AddArtistForm", () => { | ||
| beforeEach(() => { | ||
| addArtist.mockClear(); | ||
| addArtist.mockResolvedValue(true); | ||
| }); | ||
|
|
||
| const openForm = () => { | ||
| render(<AddArtistForm />); | ||
| fireEvent.click(screen.getByRole("button", { name: /add another artist/i })); | ||
| }; | ||
|
|
||
| it("adds by Spotify typeahead, not a free-text name field", () => { | ||
| openForm(); | ||
|
|
||
| // The free-text input produced artists with no Spotify id, so no catalog and | ||
| // no valuation could follow — the whole payoff was unreachable for anyone | ||
| // who arrived without a funnel valuation (chat#1889). | ||
| expect(screen.queryByLabelText(/^artist name$/i)).toBeNull(); | ||
| expect(screen.getByLabelText(/search spotify for an artist/i)).toBeDefined(); | ||
| }); | ||
|
|
||
| it("resolves the picked artist to its Spotify profile URL and avatar", () => { | ||
| 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, | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| // @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({ account_id: "artist-1" }); | ||
| 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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,21 +4,39 @@ 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 another artist" for the onboarding roster step. Creates the | ||||||||||||||||||||||||||||||||||||||||||||||
| * artist through the existing `POST /api/artists` endpoint and refreshes | ||||||||||||||||||||||||||||||||||||||||||||||
| * the shared roster (ArtistProvider) so the new artist appears in-flow. | ||||||||||||||||||||||||||||||||||||||||||||||
| * "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): Promise<boolean> => { | ||||||||||||||||||||||||||||||||||||||||||||||
| const addArtist = async ( | ||||||||||||||||||||||||||||||||||||||||||||||
| name: string, | ||||||||||||||||||||||||||||||||||||||||||||||
| options: AddRosterArtistOptions = {}, | ||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<boolean> => { | ||||||||||||||||||||||||||||||||||||||||||||||
| const trimmed = name.trim(); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (!trimmed) return false; | ||||||||||||||||||||||||||||||||||||||||||||||
| setIsAdding(true); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -27,7 +45,29 @@ export function useAddRosterArtist() { | |||||||||||||||||||||||||||||||||||||||||||||
| if (!accessToken) { | ||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error("Please sign in to add an artist"); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| await createRosterArtist(accessToken, trimmed, selectedOrgId); | ||||||||||||||||||||||||||||||||||||||||||||||
| 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. | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Comment on lines
+54
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Keep Spotify enrichment failures non-fatal. A Proposed fix if (artist?.account_id && (payload || options.image)) {
- await saveArtist(accessToken, artist.account_id, {
- ...(payload ? { profileUrls: payload.profileUrls } : {}),
- ...(options.image ? { image: options.image } : {}),
- });
+ try {
+ await saveArtist(accessToken, artist.account_id, {
+ ...(payload ? { profileUrls: payload.profileUrls } : {}),
+ ...(options.image ? { image: options.image } : {}),
+ });
+ } catch {
+ toast.error("Artist added, but Spotify details could not be saved");
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| await getArtists(); | ||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: Adding the first artist from an empty onboarding roster still never starts catalog valuation, so users can continue past this step without the catalog the new copy promises. This handler has the selected
artist.id; route it throughuseAddSpotifyArtist(or trigger the same first-roster valuation flow here) rather than the name/profile-only hook.Prompt for AI agents