Skip to content
Merged
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
47 changes: 25 additions & 22 deletions components/Onboarding/AddArtistForm.tsx
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, {

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: 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 through useAddSpotifyArtist (or trigger the same first-roster valuation flow here) rather than the name/profile-only hook.

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

<comment>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 through `useAddSpotifyArtist` (or trigger the same first-roster valuation flow here) rather than the name/profile-only hook.</comment>

<file context>
@@ -1,24 +1,31 @@
-      setIsOpen(false);
-    }
+  const handleSelect = async (artist: SpotifyArtistSearchResult) => {
+    const added = await addArtist(artist.name, {
+      profileUrl: artist.external_urls.spotify,
+      image: artist.images?.[0]?.url,
</file context>

profileUrl: artist.external_urls.spotify,
image: artist.images?.[0]?.url,
});
Comment on lines +24 to +27

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 Seed a valuation from the selected Spotify artist

For a cold-start account with no catalogs, this selection passes only the artist name, profile URL, and image, discarding artist.id; the hook then performs only the artist POST/PATCH. Unlike hooks/useAddSpotifyArtist.ts, it never calls runValuation(accessToken, artist.id), so /setup/catalog can still redirect these users to a catalogs page that says "No catalogs found" despite the new copy promising that adding the artist gets the catalog valued.

Useful? React with 👍 / 👎.

if (added) setIsOpen(false);
};

if (!isOpen) {
Expand All @@ -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>
);
};

Expand Down
16 changes: 11 additions & 5 deletions components/Onboarding/ConfirmRosterStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 -200

Repository: recoupable/chat

Length of output: 21063


Preserve the unresolved catalog state.

useCatalogs can render with data === undefined before the catalog query resolves, and hasValuation then treats that as false. A populated roster can briefly show “Add anyone else you manage” instead of “We set [subject] up from your valuation.” Distinguish loading from “no valuation” and defer this copy until catalog state is known.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/Onboarding/ConfirmRosterStep.tsx` around lines 18 - 20, Update the
catalog state handling near useCatalogs and hasValuation so data === undefined
remains an unresolved state rather than being treated as no valuation. Defer the
valuation-specific copy until catalogsData is known, while preserving the
existing no-valuation behavior for a resolved empty catalogs list.


return (
<section className="flex flex-col gap-6">
Expand All @@ -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>

Expand All @@ -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) => (
Expand All @@ -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>
Expand Down
4 changes: 2 additions & 2 deletions components/Onboarding/VerifySocialsStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const VerifySocialsStep = ({ onConfirmed }: { onConfirmed: () => void }) => {
</h1>
<p className="text-sm text-muted-foreground mt-1">
These are the profiles we matched. Fix any that point at the wrong
account — reports and tasks pull from them.
account. Reports and tasks pull from them.
</p>
</div>

Expand All @@ -40,7 +40,7 @@ const VerifySocialsStep = ({ onConfirmed }: { onConfirmed: () => void }) => {
</div>

<Button type="button" className="w-full" onClick={onConfirmed}>
Looks good continue
Looks good, continue
</Button>
</section>
);
Expand Down
58 changes: 58 additions & 0 deletions components/Onboarding/__tests__/AddArtistForm.test.tsx
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,
});
});
});
93 changes: 93 additions & 0 deletions hooks/onboarding/__tests__/useAddRosterArtist.test.tsx
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();
});
});
50 changes: 45 additions & 5 deletions hooks/onboarding/useAddRosterArtist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.
}
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment on lines +54 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 saveArtist rejection reaches the outer catch, so a successfully created artist is reported as failed and the roster is not refreshed. The form remains open, and retrying can create a duplicate artist. Catch only the PATCH failure, show an enrichment-specific message, then refresh and return success.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const payload = options.profileUrl
? buildSocialFixPayload(options.profileUrl)
: null;
if (artist?.account_id && (payload || options.image)) {
await saveArtist(accessToken, artist.account_id, {
...(payload ? { profileUrls: payload.profileUrls } : {}),
...(options.image ? { image: options.image } : {}),
});
}
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 {
toast.error("Artist added, but Spotify details could not be saved");
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/onboarding/useAddRosterArtist.ts` around lines 54 - 62, Update the
enrichment save block in useAddRosterArtist around saveArtist so its rejection
is handled locally rather than reaching the outer artist-creation catch. Show an
enrichment-specific error message, then refresh the roster and return success
after the artist has already been created; preserve normal success behavior when
enrichment succeeds and avoid treating the artist creation as failed.


await getArtists();
return true;
} catch (error) {
Expand Down
Loading
Loading