From 528523bada9b201b06bc126b1111b314b5f3b30d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 6 Jul 2026 12:24:24 -0500 Subject: [PATCH 1/7] feat: homepage valuation hero for accounts with a catalog (chat#1850) For accounts with >=1 catalog, the homepage renders the estimated catalog value (artist, mid figure with display treatment, low-to-high range, measured-track count) above the chat area, fed by the new GET /api/catalogs/measurements endpoint via composed hooks (useCatalogs -> useCatalogMeasurements -> useHomeValuation). The endpoint is still rolling out: any failure (404, error envelope, no catalogs) hides the hero and keeps the existing greeting untouched. Co-Authored-By: Claude Fable 5 --- components/Home/HomePage.tsx | 18 ++- components/Home/ValuationHero.tsx | 53 +++++++++ components/VercelChat/NewChatBootstrap.tsx | 4 + components/VercelChat/chat.tsx | 19 +++- hooks/useCatalogMeasurements.ts | 29 +++++ hooks/useHomeValuation.ts | 49 ++++++++ .../__tests__/formatValuationAmount.test.ts | 20 ++++ .../__tests__/getCatalogMeasurements.test.ts | 72 ++++++++++++ lib/catalog/formatValuationAmount.ts | 15 +++ lib/catalog/getCatalogMeasurements.ts | 55 +++++++++ .../__tests__/getValuationHeroState.test.ts | 107 ++++++++++++++++++ lib/home/getValuationHeroState.ts | 47 ++++++++ 12 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 components/Home/ValuationHero.tsx create mode 100644 hooks/useCatalogMeasurements.ts create mode 100644 hooks/useHomeValuation.ts create mode 100644 lib/catalog/__tests__/formatValuationAmount.test.ts create mode 100644 lib/catalog/__tests__/getCatalogMeasurements.test.ts create mode 100644 lib/catalog/formatValuationAmount.ts create mode 100644 lib/catalog/getCatalogMeasurements.ts create mode 100644 lib/home/__tests__/getValuationHeroState.test.ts create mode 100644 lib/home/getValuationHeroState.ts diff --git a/components/Home/HomePage.tsx b/components/Home/HomePage.tsx index 9833bd99f..b926d4108 100644 --- a/components/Home/HomePage.tsx +++ b/components/Home/HomePage.tsx @@ -2,11 +2,14 @@ import { useMiniKit } from "@coinbase/onchainkit/minikit"; import NewChatBootstrap from "../VercelChat/NewChatBootstrap"; +import ValuationHero from "./ValuationHero"; +import useHomeValuation from "@/hooks/useHomeValuation"; import { useEffect } from "react"; import { UIMessage } from "ai"; const HomePage = ({ initialMessages }: { initialMessages?: UIMessage[] }) => { const { setFrameReady, isFrameReady } = useMiniKit(); + const valuation = useHomeValuation(); useEffect(() => { if (!isFrameReady) { @@ -16,7 +19,20 @@ const HomePage = ({ initialMessages }: { initialMessages?: UIMessage[] }) => { return (
- + {valuation.show && ( +
+ +
+ )} +
); }; diff --git a/components/Home/ValuationHero.tsx b/components/Home/ValuationHero.tsx new file mode 100644 index 000000000..56f1be1b0 --- /dev/null +++ b/components/Home/ValuationHero.tsx @@ -0,0 +1,53 @@ +import ImageWithFallback from "@/components/ImageWithFallback"; +import { formatValuationAmount } from "@/lib/catalog/formatValuationAmount"; +import type { CatalogValuationBand } from "@/lib/catalog/getCatalogMeasurements"; + +interface ValuationHeroProps { + artistName: string; + artistImage: string; + valuation: CatalogValuationBand; + measuredTrackCount: number; +} + +/** + * Homepage hero: the estimated catalog value a valuation lead converted on + * (recoupable/chat#1850). Card styling follows DESIGN.md: shadow-as-border, + * achromatic chrome, display treatment on the dollar figure. + */ +const ValuationHero = ({ + artistName, + artistImage, + valuation, + measuredTrackCount, +}: ValuationHeroProps) => ( +
+
+ {artistImage && ( + + + + )} +
+ + {artistName} + + + Estimated catalog value + +
+
+

+ {formatValuationAmount(valuation.mid)} +

+

+ Range {formatValuationAmount(valuation.low)} to{" "} + {formatValuationAmount(valuation.high)} · {measuredTrackCount}{" "} + {measuredTrackCount === 1 ? "track" : "tracks"} measured +

+
+); + +export default ValuationHero; diff --git a/components/VercelChat/NewChatBootstrap.tsx b/components/VercelChat/NewChatBootstrap.tsx index 4d00d8b2b..7b64fd972 100644 --- a/components/VercelChat/NewChatBootstrap.tsx +++ b/components/VercelChat/NewChatBootstrap.tsx @@ -9,6 +9,8 @@ import { generateUUID } from "@/lib/generateUUID"; interface NewChatBootstrapProps { initialMessages?: UIMessage[]; + /** Hide the empty-state greeting when the homepage renders its own hero (chat#1850). */ + hideGreeting?: boolean; } /** @@ -25,6 +27,7 @@ interface NewChatBootstrapProps { */ export default function NewChatBootstrap({ initialMessages, + hideGreeting, }: NewChatBootstrapProps) { const state = useNewChatBootstrap(); // Stable client id so the instance (and anything typed into it) @@ -49,6 +52,7 @@ export default function NewChatBootstrap({ workflowChatId={state.status === "ready" ? state.chatId : undefined} workspaceStatus={workspaceStatus} initialMessages={initialMessages} + hideGreeting={hideGreeting} /> ); } diff --git a/components/VercelChat/chat.tsx b/components/VercelChat/chat.tsx index 204abd1f7..be48f922e 100644 --- a/components/VercelChat/chat.tsx +++ b/components/VercelChat/chat.tsx @@ -42,6 +42,12 @@ interface ChatProps { */ workspaceStatus?: WorkspaceStatus; initialMessages?: UIMessage[]; + /** + * Hide the empty-state greeting when the homepage renders the valuation + * hero above the chat area instead (recoupable/chat#1850). Defaults to + * false so every existing mount keeps the greeting. + */ + hideGreeting?: boolean; } export function Chat({ @@ -50,6 +56,7 @@ export function Chat({ workflowChatId, workspaceStatus, initialMessages, + hideGreeting, }: ChatProps) { const { selectedOrgId } = useOrganization(); const providerKey = `${id}-${selectedOrgId ?? "personal"}`; @@ -63,7 +70,7 @@ export function Chat({ workspaceStatus={workspaceStatus} initialMessages={initialMessages} > - + ); } @@ -71,9 +78,11 @@ export function Chat({ // Inner component that uses the context function ChatContentMemoized({ sessionId, + hideGreeting, }: { id: string; sessionId?: string; + hideGreeting?: boolean; }) { const { messages, status, isLoading, hasError } = useVercelChatContext(); const { chatId: routeChatId } = useParams<{ chatId?: string }>(); @@ -111,7 +120,7 @@ function ChatContentMemoized({ "px-4 md:px-0 pb-4 flex flex-col h-full items-center w-full relative", { "justify-between": messages.length > 0, - } + }, )} {...getRootProps()} > @@ -124,7 +133,7 @@ function ChatContentMemoized({ {/* Centered greeting and chat input */}
- + {!hideGreeting && }
@@ -146,6 +155,8 @@ function ChatContentMemoized({ const ChatContent = memo(ChatContentMemoized, (prevProps, nextProps) => { return ( - prevProps.id === nextProps.id && prevProps.sessionId === nextProps.sessionId + prevProps.id === nextProps.id && + prevProps.sessionId === nextProps.sessionId && + prevProps.hideGreeting === nextProps.hideGreeting ); }); diff --git a/hooks/useCatalogMeasurements.ts b/hooks/useCatalogMeasurements.ts new file mode 100644 index 000000000..4233505dd --- /dev/null +++ b/hooks/useCatalogMeasurements.ts @@ -0,0 +1,29 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import { + getCatalogMeasurements, + CatalogMeasurementsResponse, +} from "@/lib/catalog/getCatalogMeasurements"; + +const useCatalogMeasurements = ( + catalogId: string | undefined, +): UseQueryResult => { + const { getAccessToken, authenticated } = usePrivy(); + + return useQuery({ + queryKey: ["catalog-measurements", catalogId], + queryFn: async () => { + const accessToken = await getAccessToken(); + if (!accessToken) throw new Error("No access token"); + return getCatalogMeasurements(catalogId as string, accessToken); + }, + enabled: !!catalogId && authenticated, + staleTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false, + // The measurements endpoint is still rolling out (chat#1850); a 404 must + // fall back to the greeting immediately instead of retrying. + retry: false, + }); +}; + +export default useCatalogMeasurements; diff --git a/hooks/useHomeValuation.ts b/hooks/useHomeValuation.ts new file mode 100644 index 000000000..4f7ecc064 --- /dev/null +++ b/hooks/useHomeValuation.ts @@ -0,0 +1,49 @@ +import useCatalogs from "@/hooks/useCatalogs"; +import useCatalogMeasurements from "@/hooks/useCatalogMeasurements"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { getValuationHeroState } from "@/lib/home/getValuationHeroState"; +import type { CatalogValuationBand } from "@/lib/catalog/getCatalogMeasurements"; + +export type HomeValuationState = + | { show: false } + | { + show: true; + artistName: string; + artistImage: string; + valuation: CatalogValuationBand; + measuredTrackCount: number; + }; + +/** + * Composed hook behind the homepage valuation hero: account catalogs + * (existing endpoint) -> latest measurements + valuation band (new + * endpoint), plus artist identity from the provider. Auth/context come + * from providers per the chat hooks conventions; nothing here touches + * the chat transport. + */ +const useHomeValuation = (): HomeValuationState => { + const { data: catalogsData, isError: catalogsFailed } = useCatalogs(); + const catalogs = catalogsData?.catalogs; + const { data: measurements, isError: measurementsFailed } = + useCatalogMeasurements(catalogs?.[0]?.id); + const { selectedArtist } = useArtistProvider(); + + const state = getValuationHeroState({ + catalogs, + catalogsFailed, + measurements, + measurementsFailed, + }); + + if (!state.show) return { show: false }; + + return { + show: true, + artistName: selectedArtist?.name || state.catalogName, + artistImage: selectedArtist?.image || "", + valuation: state.valuation, + measuredTrackCount: state.measuredTrackCount, + }; +}; + +export default useHomeValuation; diff --git a/lib/catalog/__tests__/formatValuationAmount.test.ts b/lib/catalog/__tests__/formatValuationAmount.test.ts new file mode 100644 index 000000000..7771c4e0f --- /dev/null +++ b/lib/catalog/__tests__/formatValuationAmount.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { formatValuationAmount } from "@/lib/catalog/formatValuationAmount"; + +describe("formatValuationAmount", () => { + it("formats millions compactly", () => { + expect(formatValuationAmount(1400000)).toBe("$1.4M"); + }); + + it("formats thousands compactly", () => { + expect(formatValuationAmount(959000)).toBe("$959K"); + }); + + it("drops trailing zero decimals", () => { + expect(formatValuationAmount(2000000)).toBe("$2M"); + }); + + it("formats zero", () => { + expect(formatValuationAmount(0)).toBe("$0"); + }); +}); diff --git a/lib/catalog/__tests__/getCatalogMeasurements.test.ts b/lib/catalog/__tests__/getCatalogMeasurements.test.ts new file mode 100644 index 000000000..befcf4112 --- /dev/null +++ b/lib/catalog/__tests__/getCatalogMeasurements.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getCatalogMeasurements } from "@/lib/catalog/getCatalogMeasurements"; +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; + +vi.mock("@/lib/api/getClientApiBaseUrl", () => ({ + getClientApiBaseUrl: vi.fn(), +})); + +describe("getCatalogMeasurements", () => { + const accessToken = "test-token"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getClientApiBaseUrl).mockReturnValue( + "https://api.recoupable.com", + ); + }); + + it("calls GET /api/catalogs/measurements with catalogId and bearer auth", async () => { + const payload = { + status: "success", + measurements: [ + { isrc: "USABC1234567", playcount: 1000 }, + { isrc: "USABC1234568", playcount: 2000 }, + ], + valuation: { low: 959000, mid: 1400000, high: 2000000 }, + }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(payload), + }) as unknown as typeof fetch; + + const result = await getCatalogMeasurements("catalog-1", accessToken); + + expect(fetch).toHaveBeenCalledWith( + "https://api.recoupable.com/api/catalogs/measurements?catalogId=catalog-1", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + expect(result).toEqual(payload); + }); + + it("throws on a non-ok response (endpoint not deployed yet)", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + text: vi.fn().mockResolvedValue("Not Found"), + }) as unknown as typeof fetch; + + await expect( + getCatalogMeasurements("catalog-1", accessToken), + ).rejects.toThrow("HTTP 404"); + }); + + it("throws when the envelope reports an error", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + status: "error", + error: "Catalog not found", + }), + }) as unknown as typeof fetch; + + await expect( + getCatalogMeasurements("catalog-1", accessToken), + ).rejects.toThrow("Catalog not found"); + }); +}); diff --git a/lib/catalog/formatValuationAmount.ts b/lib/catalog/formatValuationAmount.ts new file mode 100644 index 000000000..47220afee --- /dev/null +++ b/lib/catalog/formatValuationAmount.ts @@ -0,0 +1,15 @@ +const compactUsd = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + notation: "compact", + minimumFractionDigits: 0, + maximumFractionDigits: 1, +}); + +/** + * Formats a valuation dollar amount compactly ("$1.4M", "$959K") to match + * the figure shown on the marketing valuation card. + */ +export function formatValuationAmount(amount: number): string { + return compactUsd.format(amount); +} diff --git a/lib/catalog/getCatalogMeasurements.ts b/lib/catalog/getCatalogMeasurements.ts new file mode 100644 index 000000000..f109064fe --- /dev/null +++ b/lib/catalog/getCatalogMeasurements.ts @@ -0,0 +1,55 @@ +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; + +export interface CatalogSongMeasurement { + isrc: string; + playcount?: number | null; + measured_at?: string | null; +} + +export interface CatalogValuationBand { + low: number; + mid: number; + high: number; +} + +export interface CatalogMeasurementsResponse { + status: string; + measurements: CatalogSongMeasurement[]; + valuation: CatalogValuationBand; + error?: string; +} + +/** + * Fetches the latest per-ISRC play counts + derived valuation band for a + * catalog from the Recoup API (recoupable/chat#1850 data-plumbing contract). + * + * The endpoint is being rolled out; callers must treat any thrown error as + * "no valuation available" and fall back gracefully. + */ +export async function getCatalogMeasurements( + catalogId: string, + accessToken: string, +): Promise { + const url = new URL(`${getClientApiBaseUrl()}/api/catalogs/measurements`); + url.searchParams.set("catalogId", catalogId); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const data: CatalogMeasurementsResponse = await response.json(); + + if (data.status === "error") { + throw new Error(data.error || "Failed to fetch catalog measurements"); + } + + return data; +} diff --git a/lib/home/__tests__/getValuationHeroState.test.ts b/lib/home/__tests__/getValuationHeroState.test.ts new file mode 100644 index 000000000..41da51ed7 --- /dev/null +++ b/lib/home/__tests__/getValuationHeroState.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { getValuationHeroState } from "@/lib/home/getValuationHeroState"; +import type { CatalogMeasurementsResponse } from "@/lib/catalog/getCatalogMeasurements"; +import type { Catalog } from "@/types/Catalog"; + +const catalog: Catalog = { + id: "catalog-1", + name: "Del Water Gap", + created_at: "2026-07-06T00:00:00Z", + updated_at: "2026-07-06T00:00:00Z", +}; + +const measurements: CatalogMeasurementsResponse = { + status: "success", + measurements: [ + { isrc: "USABC1234567", playcount: 1000 }, + { isrc: "USABC1234568", playcount: 2000 }, + ], + valuation: { low: 959000, mid: 1400000, high: 2000000 }, +}; + +describe("getValuationHeroState", () => { + it("hides the hero while catalogs are still loading", () => { + expect( + getValuationHeroState({ + catalogs: undefined, + catalogsFailed: false, + measurements: undefined, + measurementsFailed: false, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero when the account has no catalogs", () => { + expect( + getValuationHeroState({ + catalogs: [], + catalogsFailed: false, + measurements: undefined, + measurementsFailed: false, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero when the catalogs request failed", () => { + expect( + getValuationHeroState({ + catalogs: undefined, + catalogsFailed: true, + measurements: undefined, + measurementsFailed: false, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero when measurements are unavailable (endpoint 404/error)", () => { + expect( + getValuationHeroState({ + catalogs: [catalog], + catalogsFailed: false, + measurements: undefined, + measurementsFailed: true, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero while measurements are still loading", () => { + expect( + getValuationHeroState({ + catalogs: [catalog], + catalogsFailed: false, + measurements: undefined, + measurementsFailed: false, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero when the response has no valuation band", () => { + expect( + getValuationHeroState({ + catalogs: [catalog], + catalogsFailed: false, + measurements: { + status: "success", + measurements: [], + } as unknown as CatalogMeasurementsResponse, + measurementsFailed: false, + }), + ).toEqual({ show: false }); + }); + + it("shows the valuation and measured track count when data is complete", () => { + expect( + getValuationHeroState({ + catalogs: [catalog], + catalogsFailed: false, + measurements, + measurementsFailed: false, + }), + ).toEqual({ + show: true, + catalogName: "Del Water Gap", + valuation: { low: 959000, mid: 1400000, high: 2000000 }, + measuredTrackCount: 2, + }); + }); +}); diff --git a/lib/home/getValuationHeroState.ts b/lib/home/getValuationHeroState.ts new file mode 100644 index 000000000..25d3a35ff --- /dev/null +++ b/lib/home/getValuationHeroState.ts @@ -0,0 +1,47 @@ +import type { + CatalogMeasurementsResponse, + CatalogValuationBand, +} from "@/lib/catalog/getCatalogMeasurements"; +import type { Catalog } from "@/types/Catalog"; + +interface GetValuationHeroStateParams { + catalogs: Catalog[] | undefined; + catalogsFailed: boolean; + measurements: CatalogMeasurementsResponse | undefined; + measurementsFailed: boolean; +} + +export type ValuationHeroState = + | { show: false } + | { + show: true; + catalogName: string; + valuation: CatalogValuationBand; + measuredTrackCount: number; + }; + +/** + * Decides whether the homepage valuation hero can render. Any missing or + * failed input hides the hero so the homepage falls back to the current + * chat greeting with zero regression (recoupable/chat#1850). + */ +export function getValuationHeroState({ + catalogs, + catalogsFailed, + measurements, + measurementsFailed, +}: GetValuationHeroStateParams): ValuationHeroState { + if (catalogsFailed || measurementsFailed) return { show: false }; + + const catalog = catalogs?.[0]; + if (!catalog) return { show: false }; + + if (!measurements?.valuation) return { show: false }; + + return { + show: true, + catalogName: catalog.name, + valuation: measurements.valuation, + measuredTrackCount: measurements.measurements?.length ?? 0, + }; +} From 537fb3f6c8871198c0c1cb0aab5c237cee1a9e87 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 6 Jul 2026 16:52:13 -0500 Subject: [PATCH 2/7] =?UTF-8?q?fix(home):=20artist-scope=20the=20valuation?= =?UTF-8?q?=20hero=20=E2=80=94=20verify=20the=20artist=20has=20songs=20in?= =?UTF-8?q?=20the=20catalog=20before=20pairing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero paired the workspace's selected artist with the account's first catalog unconditionally, so switching artists kept showing another catalog's value under the new artist's name (trust-killer on multi-artist accounts, caught in live preview verification on recoupable/chat#1852). Now: with no artist selected the hero shows the whole catalog's value under the catalog name; with an artist selected it resolves the catalog by name match (findArtistCatalog), verifies the pairing via the existing GET /api/catalogs/songs artistName filter (total_count > 0, pageSize 1), and hides while resolving, on zero match, or on check failure — falling back to the greeting. Switching artists re-resolves. Co-Authored-By: Claude Fable 5 --- hooks/useHomeValuation.ts | 41 +++++-- lib/home/__tests__/findArtistCatalog.test.ts | 37 +++++++ .../__tests__/getValuationHeroState.test.ts | 100 ++++++++++++++++-- lib/home/findArtistCatalog.ts | 22 ++++ lib/home/getValuationHeroState.ts | 28 +++-- 5 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 lib/home/__tests__/findArtistCatalog.test.ts create mode 100644 lib/home/findArtistCatalog.ts diff --git a/hooks/useHomeValuation.ts b/hooks/useHomeValuation.ts index 4f7ecc064..3e748fc6b 100644 --- a/hooks/useHomeValuation.ts +++ b/hooks/useHomeValuation.ts @@ -1,6 +1,8 @@ import useCatalogs from "@/hooks/useCatalogs"; import useCatalogMeasurements from "@/hooks/useCatalogMeasurements"; +import useCatalogSongs from "@/hooks/useCatalogSongs"; import { useArtistProvider } from "@/providers/ArtistProvider"; +import { findArtistCatalog } from "@/lib/home/findArtistCatalog"; import { getValuationHeroState } from "@/lib/home/getValuationHeroState"; import type { CatalogValuationBand } from "@/lib/catalog/getCatalogMeasurements"; @@ -15,32 +17,51 @@ export type HomeValuationState = }; /** - * Composed hook behind the homepage valuation hero: account catalogs - * (existing endpoint) -> latest measurements + valuation band (new - * endpoint), plus artist identity from the provider. Auth/context come - * from providers per the chat hooks conventions; nothing here touches - * the chat transport. + * Composed hook behind the homepage valuation hero. The hero is + * artist-scoped: with an artist selected it reads the catalog matched to + * that artist and only renders once the artist verifiably has songs in it + * (songs-by-artist count > 0), re-resolving on artist switch; with no + * artist selected it shows the whole catalog's value under the catalog + * name. Auth/context come from providers per the chat hooks conventions; + * nothing here touches the chat transport (recoupable/chat#1850). */ const useHomeValuation = (): HomeValuationState => { + const { selectedArtist } = useArtistProvider(); + const selectedArtistName = selectedArtist?.name ?? null; + const { data: catalogsData, isError: catalogsFailed } = useCatalogs(); const catalogs = catalogsData?.catalogs; + const catalog = findArtistCatalog(catalogs, selectedArtistName); + const { data: measurements, isError: measurementsFailed } = - useCatalogMeasurements(catalogs?.[0]?.id); - const { selectedArtist } = useArtistProvider(); + useCatalogMeasurements(catalog?.id); + + const { data: artistSongs, isError: artistMatchFailed } = useCatalogSongs({ + catalogId: catalog?.id ?? "", + pageSize: 1, + artistName: selectedArtistName ?? undefined, + enabled: !!catalog?.id && !!selectedArtistName, + }); + const artistSongCount = artistSongs?.pages?.[0]?.pagination.total_count; const state = getValuationHeroState({ - catalogs, + catalog, catalogsFailed, measurements, measurementsFailed, + selectedArtistName, + artistSongCount, + artistMatchFailed, }); if (!state.show) return { show: false }; return { show: true, - artistName: selectedArtist?.name || state.catalogName, - artistImage: selectedArtist?.image || "", + artistName: state.showArtist + ? selectedArtistName || state.catalogName + : state.catalogName, + artistImage: state.showArtist ? selectedArtist?.image || "" : "", valuation: state.valuation, measuredTrackCount: state.measuredTrackCount, }; diff --git a/lib/home/__tests__/findArtistCatalog.test.ts b/lib/home/__tests__/findArtistCatalog.test.ts new file mode 100644 index 000000000..f84eaa9ed --- /dev/null +++ b/lib/home/__tests__/findArtistCatalog.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { findArtistCatalog } from "@/lib/home/findArtistCatalog"; +import type { Catalog } from "@/types/Catalog"; + +const makeCatalog = (id: string, name: string): Catalog => ({ + id, + name, + created_at: "2026-07-06T00:00:00Z", + updated_at: "2026-07-06T00:00:00Z", +}); + +const catalogs = [ + makeCatalog("catalog-1", "Ana Bárbara Catalog"), + makeCatalog("catalog-2", "Del Water Gap Catalog"), +]; + +describe("findArtistCatalog", () => { + it("returns undefined when there are no catalogs", () => { + expect(findArtistCatalog([], "Del Water Gap")).toBeUndefined(); + expect(findArtistCatalog(undefined, "Del Water Gap")).toBeUndefined(); + }); + + it("returns the first catalog when no artist name is given", () => { + expect(findArtistCatalog(catalogs, null)?.id).toBe("catalog-1"); + expect(findArtistCatalog(catalogs, undefined)?.id).toBe("catalog-1"); + }); + + it("prefers the catalog whose name contains the artist name (case-insensitive)", () => { + expect(findArtistCatalog(catalogs, "del water gap")?.id).toBe("catalog-2"); + }); + + it("falls back to the first catalog when no name matches", () => { + expect(findArtistCatalog(catalogs, "September Mourning")?.id).toBe( + "catalog-1", + ); + }); +}); diff --git a/lib/home/__tests__/getValuationHeroState.test.ts b/lib/home/__tests__/getValuationHeroState.test.ts index 41da51ed7..7ad76f1b7 100644 --- a/lib/home/__tests__/getValuationHeroState.test.ts +++ b/lib/home/__tests__/getValuationHeroState.test.ts @@ -23,10 +23,13 @@ describe("getValuationHeroState", () => { it("hides the hero while catalogs are still loading", () => { expect( getValuationHeroState({ - catalogs: undefined, + catalog: undefined, catalogsFailed: false, measurements: undefined, measurementsFailed: false, + selectedArtistName: null, + artistSongCount: undefined, + artistMatchFailed: false, }), ).toEqual({ show: false }); }); @@ -34,10 +37,13 @@ describe("getValuationHeroState", () => { it("hides the hero when the account has no catalogs", () => { expect( getValuationHeroState({ - catalogs: [], + catalog: undefined, catalogsFailed: false, measurements: undefined, measurementsFailed: false, + selectedArtistName: null, + artistSongCount: undefined, + artistMatchFailed: false, }), ).toEqual({ show: false }); }); @@ -45,10 +51,13 @@ describe("getValuationHeroState", () => { it("hides the hero when the catalogs request failed", () => { expect( getValuationHeroState({ - catalogs: undefined, + catalog: undefined, catalogsFailed: true, measurements: undefined, measurementsFailed: false, + selectedArtistName: null, + artistSongCount: undefined, + artistMatchFailed: false, }), ).toEqual({ show: false }); }); @@ -56,10 +65,13 @@ describe("getValuationHeroState", () => { it("hides the hero when measurements are unavailable (endpoint 404/error)", () => { expect( getValuationHeroState({ - catalogs: [catalog], + catalog, catalogsFailed: false, measurements: undefined, measurementsFailed: true, + selectedArtistName: null, + artistSongCount: undefined, + artistMatchFailed: false, }), ).toEqual({ show: false }); }); @@ -67,10 +79,13 @@ describe("getValuationHeroState", () => { it("hides the hero while measurements are still loading", () => { expect( getValuationHeroState({ - catalogs: [catalog], + catalog, catalogsFailed: false, measurements: undefined, measurementsFailed: false, + selectedArtistName: null, + artistSongCount: undefined, + artistMatchFailed: false, }), ).toEqual({ show: false }); }); @@ -78,27 +93,96 @@ describe("getValuationHeroState", () => { it("hides the hero when the response has no valuation band", () => { expect( getValuationHeroState({ - catalogs: [catalog], + catalog, catalogsFailed: false, measurements: { status: "success", measurements: [], } as unknown as CatalogMeasurementsResponse, measurementsFailed: false, + selectedArtistName: null, + artistSongCount: undefined, + artistMatchFailed: false, }), ).toEqual({ show: false }); }); - it("shows the valuation and measured track count when data is complete", () => { + it("shows the whole-catalog valuation (catalog label) when no artist is selected", () => { expect( getValuationHeroState({ - catalogs: [catalog], + catalog, catalogsFailed: false, measurements, measurementsFailed: false, + selectedArtistName: null, + artistSongCount: undefined, + artistMatchFailed: false, }), ).toEqual({ show: true, + showArtist: false, + catalogName: "Del Water Gap", + valuation: { low: 959000, mid: 1400000, high: 2000000 }, + measuredTrackCount: 2, + }); + }); + + it("hides the hero while the selected artist's catalog match is still resolving", () => { + expect( + getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements, + measurementsFailed: false, + selectedArtistName: "Del Water Gap", + artistSongCount: undefined, + artistMatchFailed: false, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero when the selected artist has no songs in the catalog", () => { + expect( + getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements, + measurementsFailed: false, + selectedArtistName: "Ana Bárbara", + artistSongCount: 0, + artistMatchFailed: false, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero when the artist match check failed", () => { + expect( + getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements, + measurementsFailed: false, + selectedArtistName: "Del Water Gap", + artistSongCount: undefined, + artistMatchFailed: true, + }), + ).toEqual({ show: false }); + }); + + it("shows the artist-labeled hero when the selected artist has songs in the catalog", () => { + expect( + getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements, + measurementsFailed: false, + selectedArtistName: "Del Water Gap", + artistSongCount: 67, + artistMatchFailed: false, + }), + ).toEqual({ + show: true, + showArtist: true, catalogName: "Del Water Gap", valuation: { low: 959000, mid: 1400000, high: 2000000 }, measuredTrackCount: 2, diff --git a/lib/home/findArtistCatalog.ts b/lib/home/findArtistCatalog.ts new file mode 100644 index 000000000..0a0beb82e --- /dev/null +++ b/lib/home/findArtistCatalog.ts @@ -0,0 +1,22 @@ +import type { Catalog } from "@/types/Catalog"; + +/** + * Picks which of the account's catalogs the homepage hero should read for + * the selected artist. Catalog names follow the marketing claim convention + * ("{Artist} Catalog"), so a case-insensitive name match wins; otherwise + * the first catalog stands in and the songs-by-artist check in + * useHomeValuation decides whether the pairing is real (recoupable/chat#1850). + */ +export function findArtistCatalog( + catalogs: Catalog[] | undefined, + artistName: string | null | undefined, +): Catalog | undefined { + if (!catalogs?.length) return undefined; + if (!artistName) return catalogs[0]; + + const needle = artistName.toLowerCase(); + return ( + catalogs.find((catalog) => catalog.name.toLowerCase().includes(needle)) ?? + catalogs[0] + ); +} diff --git a/lib/home/getValuationHeroState.ts b/lib/home/getValuationHeroState.ts index 25d3a35ff..21cbf969d 100644 --- a/lib/home/getValuationHeroState.ts +++ b/lib/home/getValuationHeroState.ts @@ -5,41 +5,57 @@ import type { import type { Catalog } from "@/types/Catalog"; interface GetValuationHeroStateParams { - catalogs: Catalog[] | undefined; + catalog: Catalog | undefined; catalogsFailed: boolean; measurements: CatalogMeasurementsResponse | undefined; measurementsFailed: boolean; + selectedArtistName: string | null; + artistSongCount: number | undefined; + artistMatchFailed: boolean; } export type ValuationHeroState = | { show: false } | { show: true; + showArtist: boolean; catalogName: string; valuation: CatalogValuationBand; measuredTrackCount: number; }; /** - * Decides whether the homepage valuation hero can render. Any missing or - * failed input hides the hero so the homepage falls back to the current - * chat greeting with zero regression (recoupable/chat#1850). + * Decides whether the homepage valuation hero can render, and under which + * label. With no artist selected the hero shows the whole catalog's value + * (catalog label). With an artist selected it only shows once the artist is + * confirmed to have songs in the catalog (`artistSongCount > 0`) — never the + * wrong artist over another catalog's money. Any missing, unresolved, or + * failed input hides the hero so the homepage falls back to the chat + * greeting with zero regression (recoupable/chat#1850). */ export function getValuationHeroState({ - catalogs, + catalog, catalogsFailed, measurements, measurementsFailed, + selectedArtistName, + artistSongCount, + artistMatchFailed, }: GetValuationHeroStateParams): ValuationHeroState { if (catalogsFailed || measurementsFailed) return { show: false }; - const catalog = catalogs?.[0]; if (!catalog) return { show: false }; if (!measurements?.valuation) return { show: false }; + if (selectedArtistName) { + if (artistMatchFailed) return { show: false }; + if (!artistSongCount) return { show: false }; + } + return { show: true, + showArtist: !!selectedArtistName, catalogName: catalog.name, valuation: measurements.valuation, measuredTrackCount: measurements.measurements?.length ?? 0, From 7355a7752ec8e3192aa64032555c6c75ad1ff21e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 6 Jul 2026 17:12:48 -0500 Subject: [PATCH 3/7] =?UTF-8?q?fix(home):=20verify=20the=20artist/catalog?= =?UTF-8?q?=20pairing=20client-side=20=E2=80=94=20the=20api=20artistName?= =?UTF-8?q?=20filter=20no-ops=20on=20unlinked=20songs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live preview verification caught the api-side hole: GET /api/catalogs/songs with artistName=Ana+Bárbara returned all 6 songs (total_count 6, artists:[null]) of a catalog she has no songs in — the filter rides the artists join that api#681 relaxed to a LEFT-join, so unlinked songs pass any artist filter. The hero therefore now fetches the catalog's songs unfiltered and matches client-side (isArtistCatalogMatch): catalog named for the artist, or the artist present in a song's artists array. Null- or unlinked songs match nobody. api-side filter fix tracked on recoupable/chat#1850. Co-Authored-By: Claude Fable 5 --- hooks/useHomeValuation.ts | 24 +++++-- .../__tests__/getValuationHeroState.test.ts | 24 +++---- .../__tests__/isArtistCatalogMatch.test.ts | 66 +++++++++++++++++++ lib/home/getValuationHeroState.ts | 8 +-- lib/home/isArtistCatalogMatch.ts | 37 +++++++++++ 5 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 lib/home/__tests__/isArtistCatalogMatch.test.ts create mode 100644 lib/home/isArtistCatalogMatch.ts diff --git a/hooks/useHomeValuation.ts b/hooks/useHomeValuation.ts index 3e748fc6b..38c7b3b77 100644 --- a/hooks/useHomeValuation.ts +++ b/hooks/useHomeValuation.ts @@ -1,6 +1,7 @@ import useCatalogs from "@/hooks/useCatalogs"; import useCatalogMeasurements from "@/hooks/useCatalogMeasurements"; import useCatalogSongs from "@/hooks/useCatalogSongs"; +import { isArtistCatalogMatch } from "@/lib/home/isArtistCatalogMatch"; import { useArtistProvider } from "@/providers/ArtistProvider"; import { findArtistCatalog } from "@/lib/home/findArtistCatalog"; import { getValuationHeroState } from "@/lib/home/getValuationHeroState"; @@ -19,8 +20,10 @@ export type HomeValuationState = /** * Composed hook behind the homepage valuation hero. The hero is * artist-scoped: with an artist selected it reads the catalog matched to - * that artist and only renders once the artist verifiably has songs in it - * (songs-by-artist count > 0), re-resolving on artist switch; with no + * that artist and only renders once the artist verifiably matches it + * (catalog named for the artist, or the artist appears on its songs — + * checked client-side because the api's artistName filter no-ops on + * unlinked songs), re-resolving on artist switch; with no * artist selected it shows the whole catalog's value under the catalog * name. Auth/context come from providers per the chat hooks conventions; * nothing here touches the chat transport (recoupable/chat#1850). @@ -36,13 +39,20 @@ const useHomeValuation = (): HomeValuationState => { const { data: measurements, isError: measurementsFailed } = useCatalogMeasurements(catalog?.id); - const { data: artistSongs, isError: artistMatchFailed } = useCatalogSongs({ + const { data: catalogSongs, isError: artistMatchFailed } = useCatalogSongs({ catalogId: catalog?.id ?? "", - pageSize: 1, - artistName: selectedArtistName ?? undefined, + pageSize: 50, enabled: !!catalog?.id && !!selectedArtistName, }); - const artistSongCount = artistSongs?.pages?.[0]?.pagination.total_count; + const songsPage = catalogSongs?.pages?.[0]?.songs; + const artistMatched = + catalog && selectedArtistName && songsPage + ? isArtistCatalogMatch({ + catalogName: catalog.name, + artistName: selectedArtistName, + songs: songsPage, + }) + : undefined; const state = getValuationHeroState({ catalog, @@ -50,7 +60,7 @@ const useHomeValuation = (): HomeValuationState => { measurements, measurementsFailed, selectedArtistName, - artistSongCount, + artistMatched, artistMatchFailed, }); diff --git a/lib/home/__tests__/getValuationHeroState.test.ts b/lib/home/__tests__/getValuationHeroState.test.ts index 7ad76f1b7..91dede244 100644 --- a/lib/home/__tests__/getValuationHeroState.test.ts +++ b/lib/home/__tests__/getValuationHeroState.test.ts @@ -28,7 +28,7 @@ describe("getValuationHeroState", () => { measurements: undefined, measurementsFailed: false, selectedArtistName: null, - artistSongCount: undefined, + artistMatched: undefined, artistMatchFailed: false, }), ).toEqual({ show: false }); @@ -42,7 +42,7 @@ describe("getValuationHeroState", () => { measurements: undefined, measurementsFailed: false, selectedArtistName: null, - artistSongCount: undefined, + artistMatched: undefined, artistMatchFailed: false, }), ).toEqual({ show: false }); @@ -56,7 +56,7 @@ describe("getValuationHeroState", () => { measurements: undefined, measurementsFailed: false, selectedArtistName: null, - artistSongCount: undefined, + artistMatched: undefined, artistMatchFailed: false, }), ).toEqual({ show: false }); @@ -70,7 +70,7 @@ describe("getValuationHeroState", () => { measurements: undefined, measurementsFailed: true, selectedArtistName: null, - artistSongCount: undefined, + artistMatched: undefined, artistMatchFailed: false, }), ).toEqual({ show: false }); @@ -84,7 +84,7 @@ describe("getValuationHeroState", () => { measurements: undefined, measurementsFailed: false, selectedArtistName: null, - artistSongCount: undefined, + artistMatched: undefined, artistMatchFailed: false, }), ).toEqual({ show: false }); @@ -101,7 +101,7 @@ describe("getValuationHeroState", () => { } as unknown as CatalogMeasurementsResponse, measurementsFailed: false, selectedArtistName: null, - artistSongCount: undefined, + artistMatched: undefined, artistMatchFailed: false, }), ).toEqual({ show: false }); @@ -115,7 +115,7 @@ describe("getValuationHeroState", () => { measurements, measurementsFailed: false, selectedArtistName: null, - artistSongCount: undefined, + artistMatched: undefined, artistMatchFailed: false, }), ).toEqual({ @@ -135,13 +135,13 @@ describe("getValuationHeroState", () => { measurements, measurementsFailed: false, selectedArtistName: "Del Water Gap", - artistSongCount: undefined, + artistMatched: undefined, artistMatchFailed: false, }), ).toEqual({ show: false }); }); - it("hides the hero when the selected artist has no songs in the catalog", () => { + it("hides the hero when the selected artist does not match the catalog", () => { expect( getValuationHeroState({ catalog, @@ -149,7 +149,7 @@ describe("getValuationHeroState", () => { measurements, measurementsFailed: false, selectedArtistName: "Ana Bárbara", - artistSongCount: 0, + artistMatched: false, artistMatchFailed: false, }), ).toEqual({ show: false }); @@ -163,7 +163,7 @@ describe("getValuationHeroState", () => { measurements, measurementsFailed: false, selectedArtistName: "Del Water Gap", - artistSongCount: undefined, + artistMatched: undefined, artistMatchFailed: true, }), ).toEqual({ show: false }); @@ -177,7 +177,7 @@ describe("getValuationHeroState", () => { measurements, measurementsFailed: false, selectedArtistName: "Del Water Gap", - artistSongCount: 67, + artistMatched: true, artistMatchFailed: false, }), ).toEqual({ diff --git a/lib/home/__tests__/isArtistCatalogMatch.test.ts b/lib/home/__tests__/isArtistCatalogMatch.test.ts new file mode 100644 index 000000000..2fe006b44 --- /dev/null +++ b/lib/home/__tests__/isArtistCatalogMatch.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { isArtistCatalogMatch } from "@/lib/home/isArtistCatalogMatch"; + +const makeSong = (artists: unknown[]) => ({ artists }) as never; + +describe("isArtistCatalogMatch", () => { + it("matches when the catalog name contains the artist name (case-insensitive)", () => { + expect( + isArtistCatalogMatch({ + catalogName: "Del Water Gap Catalog", + artistName: "del water gap", + songs: [], + }), + ).toBe(true); + }); + + it("matches when a song's artists include the artist (object shape)", () => { + expect( + isArtistCatalogMatch({ + catalogName: "[TEST] verification", + artistName: "Del Water Gap", + songs: [makeSong([{ name: "Del Water Gap" }])], + }), + ).toBe(true); + }); + + it("matches when a song's artists include the artist (string shape)", () => { + expect( + isArtistCatalogMatch({ + catalogName: "[TEST] verification", + artistName: "Del Water Gap", + songs: [makeSong(["Del Water Gap"])], + }), + ).toBe(true); + }); + + it("does not match null-linked songs (unenriched catalogs match nobody)", () => { + expect( + isArtistCatalogMatch({ + catalogName: "[TEST] verification", + artistName: "Ana Bárbara", + songs: [makeSong([null]), makeSong([null])], + }), + ).toBe(false); + }); + + it("does not match a different artist", () => { + expect( + isArtistCatalogMatch({ + catalogName: "[TEST] verification", + artistName: "Ana Bárbara", + songs: [makeSong([{ name: "Del Water Gap" }])], + }), + ).toBe(false); + }); + + it("does not match while songs are unknown", () => { + expect( + isArtistCatalogMatch({ + catalogName: "[TEST] verification", + artistName: "Ana Bárbara", + songs: undefined, + }), + ).toBe(false); + }); +}); diff --git a/lib/home/getValuationHeroState.ts b/lib/home/getValuationHeroState.ts index 21cbf969d..4c0b305b2 100644 --- a/lib/home/getValuationHeroState.ts +++ b/lib/home/getValuationHeroState.ts @@ -10,7 +10,7 @@ interface GetValuationHeroStateParams { measurements: CatalogMeasurementsResponse | undefined; measurementsFailed: boolean; selectedArtistName: string | null; - artistSongCount: number | undefined; + artistMatched: boolean | undefined; artistMatchFailed: boolean; } @@ -28,7 +28,7 @@ export type ValuationHeroState = * Decides whether the homepage valuation hero can render, and under which * label. With no artist selected the hero shows the whole catalog's value * (catalog label). With an artist selected it only shows once the artist is - * confirmed to have songs in the catalog (`artistSongCount > 0`) — never the + * confirmed to belong to the catalog (`artistMatched`) — never the * wrong artist over another catalog's money. Any missing, unresolved, or * failed input hides the hero so the homepage falls back to the chat * greeting with zero regression (recoupable/chat#1850). @@ -39,7 +39,7 @@ export function getValuationHeroState({ measurements, measurementsFailed, selectedArtistName, - artistSongCount, + artistMatched, artistMatchFailed, }: GetValuationHeroStateParams): ValuationHeroState { if (catalogsFailed || measurementsFailed) return { show: false }; @@ -50,7 +50,7 @@ export function getValuationHeroState({ if (selectedArtistName) { if (artistMatchFailed) return { show: false }; - if (!artistSongCount) return { show: false }; + if (!artistMatched) return { show: false }; } return { diff --git a/lib/home/isArtistCatalogMatch.ts b/lib/home/isArtistCatalogMatch.ts new file mode 100644 index 000000000..5f735b668 --- /dev/null +++ b/lib/home/isArtistCatalogMatch.ts @@ -0,0 +1,37 @@ +interface ArtistCatalogMatchParams { + catalogName: string; + artistName: string; + songs: { artists?: unknown[] | null }[] | undefined; +} + +const artistEntryName = (entry: unknown): string | undefined => { + if (typeof entry === "string") return entry; + if (entry && typeof entry === "object" && "name" in entry) { + const { name } = entry as { name?: unknown }; + if (typeof name === "string") return name; + } + return undefined; +}; + +/** + * Whether a catalog verifiably belongs to the selected artist: either the + * catalog is named for the artist (marketing's "{Artist} Catalog" claim + * convention) or one of its songs carries the artist in `song_artists`. + * Songs with null/unlinked artists match nobody — the api's `artistName` + * query filter can't be used for this because it no-ops on unlinked songs + * (LEFT-join since api#681; see recoupable/chat#1850). + */ +export function isArtistCatalogMatch({ + catalogName, + artistName, + songs, +}: ArtistCatalogMatchParams): boolean { + const needle = artistName.toLowerCase(); + if (catalogName.toLowerCase().includes(needle)) return true; + + return (songs ?? []).some((song) => + (song.artists ?? []).some( + (entry) => artistEntryName(entry)?.toLowerCase() === needle, + ), + ); +} From 67afc145bc7a0355be5dc79b7604dba8af15c0ef Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Jul 2026 07:07:12 -0500 Subject: [PATCH 4/7] refactor(home): artist-scope the hero via the v2 measurements filter; delete the client-side matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero now passes the selected artist's account_id as artist_account_id to GET /api/catalogs/measurements (docs#267 contract, api#763 impl): the api scopes measurements + valuation server-side via catalog_songs ∩ song_artists over the account's single catalog. The response echoes the applied filter and the resolver requires the echo to match before rendering an artist-labeled value — a pre-v2 deployment ignores the unknown param and the hero hides instead of showing whole-catalog money under an artist's name (deploy-skew safe). Deletes findArtistCatalog + isArtistCatalogMatch and their tests: the name-convention matching and 50-song sampling (which missed artists deeper in large catalogs) are subsumed by the server-side filter. Supersedes the per-artist-catalog prototype (rejected 2026-07-06, see recoupable/chat#1850 decision callout). Co-Authored-By: Claude Fable 5 --- hooks/useCatalogMeasurements.ts | 9 +- hooks/useHomeValuation.ts | 45 ++---- .../__tests__/getCatalogMeasurements.test.ts | 46 ++++++ lib/catalog/createCatalogResult.ts | 2 +- lib/catalog/createSearchResult.ts | 3 +- lib/catalog/formatArtists.ts | 3 +- lib/catalog/getCatalogMeasurements.ts | 14 +- lib/catalog/getCatalogSongs.ts | 4 +- lib/catalog/parseCsvFile.ts | 6 +- lib/catalog/postCatalogSongs.ts | 17 ++- lib/catalog/uploadBatchSongs.ts | 2 +- lib/home/__tests__/findArtistCatalog.test.ts | 37 ----- .../__tests__/getValuationHeroState.test.ts | 136 ++++++++++-------- .../__tests__/isArtistCatalogMatch.test.ts | 66 --------- lib/home/findArtistCatalog.ts | 22 --- lib/home/getValuationHeroState.ts | 33 +++-- lib/home/isArtistCatalogMatch.ts | 37 ----- 17 files changed, 193 insertions(+), 289 deletions(-) delete mode 100644 lib/home/__tests__/findArtistCatalog.test.ts delete mode 100644 lib/home/__tests__/isArtistCatalogMatch.test.ts delete mode 100644 lib/home/findArtistCatalog.ts delete mode 100644 lib/home/isArtistCatalogMatch.ts diff --git a/hooks/useCatalogMeasurements.ts b/hooks/useCatalogMeasurements.ts index 4233505dd..78bd27a14 100644 --- a/hooks/useCatalogMeasurements.ts +++ b/hooks/useCatalogMeasurements.ts @@ -7,15 +7,20 @@ import { const useCatalogMeasurements = ( catalogId: string | undefined, + artistAccountId?: string, ): UseQueryResult => { const { getAccessToken, authenticated } = usePrivy(); return useQuery({ - queryKey: ["catalog-measurements", catalogId], + queryKey: ["catalog-measurements", catalogId, artistAccountId ?? null], queryFn: async () => { const accessToken = await getAccessToken(); if (!accessToken) throw new Error("No access token"); - return getCatalogMeasurements(catalogId as string, accessToken); + return getCatalogMeasurements( + catalogId as string, + accessToken, + artistAccountId, + ); }, enabled: !!catalogId && authenticated, staleTime: 5 * 60 * 1000, // 5 minutes diff --git a/hooks/useHomeValuation.ts b/hooks/useHomeValuation.ts index 38c7b3b77..9754c6a63 100644 --- a/hooks/useHomeValuation.ts +++ b/hooks/useHomeValuation.ts @@ -1,9 +1,6 @@ import useCatalogs from "@/hooks/useCatalogs"; import useCatalogMeasurements from "@/hooks/useCatalogMeasurements"; -import useCatalogSongs from "@/hooks/useCatalogSongs"; -import { isArtistCatalogMatch } from "@/lib/home/isArtistCatalogMatch"; import { useArtistProvider } from "@/providers/ArtistProvider"; -import { findArtistCatalog } from "@/lib/home/findArtistCatalog"; import { getValuationHeroState } from "@/lib/home/getValuationHeroState"; import type { CatalogValuationBand } from "@/lib/catalog/getCatalogMeasurements"; @@ -18,41 +15,26 @@ export type HomeValuationState = }; /** - * Composed hook behind the homepage valuation hero. The hero is - * artist-scoped: with an artist selected it reads the catalog matched to - * that artist and only renders once the artist verifiably matches it - * (catalog named for the artist, or the artist appears on its songs — - * checked client-side because the api's artistName filter no-ops on - * unlinked songs), re-resolving on artist switch; with no - * artist selected it shows the whole catalog's value under the catalog - * name. Auth/context come from providers per the chat hooks conventions; - * nothing here touches the chat transport (recoupable/chat#1850). + * Composed hook behind the homepage valuation hero. The hero reads the + * account's catalog through the measurements endpoint; with an artist + * selected the read is scoped server-side via artist_account_id + * (catalog_songs ∩ song_artists) and only renders when the response echoes + * that scope — a pre-v2 api ignores the param and gets hidden instead of + * showing whole-catalog money under an artist label. With no artist + * selected it shows the whole catalog's value under the catalog name. + * Auth/context come from providers per the chat hooks conventions; nothing + * here touches the chat transport (recoupable/chat#1850). */ const useHomeValuation = (): HomeValuationState => { const { selectedArtist } = useArtistProvider(); const selectedArtistName = selectedArtist?.name ?? null; + const selectedArtistAccountId = selectedArtist?.account_id ?? null; const { data: catalogsData, isError: catalogsFailed } = useCatalogs(); - const catalogs = catalogsData?.catalogs; - const catalog = findArtistCatalog(catalogs, selectedArtistName); + const catalog = catalogsData?.catalogs?.[0]; const { data: measurements, isError: measurementsFailed } = - useCatalogMeasurements(catalog?.id); - - const { data: catalogSongs, isError: artistMatchFailed } = useCatalogSongs({ - catalogId: catalog?.id ?? "", - pageSize: 50, - enabled: !!catalog?.id && !!selectedArtistName, - }); - const songsPage = catalogSongs?.pages?.[0]?.songs; - const artistMatched = - catalog && selectedArtistName && songsPage - ? isArtistCatalogMatch({ - catalogName: catalog.name, - artistName: selectedArtistName, - songs: songsPage, - }) - : undefined; + useCatalogMeasurements(catalog?.id, selectedArtistAccountId ?? undefined); const state = getValuationHeroState({ catalog, @@ -60,8 +42,7 @@ const useHomeValuation = (): HomeValuationState => { measurements, measurementsFailed, selectedArtistName, - artistMatched, - artistMatchFailed, + selectedArtistAccountId, }); if (!state.show) return { show: false }; diff --git a/lib/catalog/__tests__/getCatalogMeasurements.test.ts b/lib/catalog/__tests__/getCatalogMeasurements.test.ts index befcf4112..ffa16393f 100644 --- a/lib/catalog/__tests__/getCatalogMeasurements.test.ts +++ b/lib/catalog/__tests__/getCatalogMeasurements.test.ts @@ -44,6 +44,52 @@ describe("getCatalogMeasurements", () => { expect(result).toEqual(payload); }); + it("passes artist_account_id and returns the scope echo when artist-scoped", async () => { + const payload = { + status: "success", + measurements: [{ isrc: "USABC1234567", playcount: 1000 }], + valuation: { low: 100, mid: 200, high: 300 }, + artist_account_id: "b1814076-8e19-4a77-9dea-2ec150e26aaa", + }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(payload), + }) as unknown as typeof fetch; + + const result = await getCatalogMeasurements( + "catalog-1", + accessToken, + "b1814076-8e19-4a77-9dea-2ec150e26aaa", + ); + + expect(fetch).toHaveBeenCalledWith( + "https://api.recoupable.com/api/catalogs/measurements?catalogId=catalog-1&artist_account_id=b1814076-8e19-4a77-9dea-2ec150e26aaa", + expect.objectContaining({ method: "GET" }), + ); + expect(result.artist_account_id).toBe( + "b1814076-8e19-4a77-9dea-2ec150e26aaa", + ); + }); + + it("omits artist_account_id from the query when not provided", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + status: "success", + measurements: [], + valuation: { low: 0, mid: 0, high: 0 }, + artist_account_id: null, + }), + }) as unknown as typeof fetch; + + await getCatalogMeasurements("catalog-1", accessToken); + + expect(fetch).toHaveBeenCalledWith( + "https://api.recoupable.com/api/catalogs/measurements?catalogId=catalog-1", + expect.anything(), + ); + }); + it("throws on a non-ok response (endpoint not deployed yet)", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, diff --git a/lib/catalog/createCatalogResult.ts b/lib/catalog/createCatalogResult.ts index b78bff525..761df5fba 100644 --- a/lib/catalog/createCatalogResult.ts +++ b/lib/catalog/createCatalogResult.ts @@ -5,7 +5,7 @@ import { CatalogSongsResponse } from "./getCatalogSongs"; * Creates a CatalogSongsResult from paginated catalog data */ export const createCatalogResult = ( - pages: CatalogSongsResponse[] + pages: CatalogSongsResponse[], ): CatalogSongsResult => { const allSongs = pages.flatMap((page) => page.songs); const totalCount = pages[0].pagination.total_count; diff --git a/lib/catalog/createSearchResult.ts b/lib/catalog/createSearchResult.ts index e81e02109..1d4d80cee 100644 --- a/lib/catalog/createSearchResult.ts +++ b/lib/catalog/createSearchResult.ts @@ -7,7 +7,7 @@ import { SongsByIsrcResponse } from "./getSongsByIsrc"; export const createSearchResult = ( searchData: SongsByIsrcResponse, catalogId: string, - activeIsrc: string + activeIsrc: string, ): CatalogSongsResult => ({ success: searchData.status === "success", songs: searchData.songs.map((song) => ({ @@ -27,4 +27,3 @@ export const createSearchResult = ( : `No songs found for ISRC: ${activeIsrc}`, error: searchData.error, }); - diff --git a/lib/catalog/formatArtists.ts b/lib/catalog/formatArtists.ts index 1f220c9bd..ec996be2d 100644 --- a/lib/catalog/formatArtists.ts +++ b/lib/catalog/formatArtists.ts @@ -5,9 +5,8 @@ import { CatalogSongsResponse } from "./getCatalogSongs"; * Returns "—" if no artists exist */ export const formatArtists = ( - artists: CatalogSongsResponse["songs"][0]["artists"] + artists: CatalogSongsResponse["songs"][0]["artists"], ): string => { if (!artists || artists.length === 0) return "—"; return artists.map((artist) => artist.name).join(", "); }; - diff --git a/lib/catalog/getCatalogMeasurements.ts b/lib/catalog/getCatalogMeasurements.ts index f109064fe..442a5769c 100644 --- a/lib/catalog/getCatalogMeasurements.ts +++ b/lib/catalog/getCatalogMeasurements.ts @@ -16,12 +16,20 @@ export interface CatalogMeasurementsResponse { status: string; measurements: CatalogSongMeasurement[]; valuation: CatalogValuationBand; + /** + * Echoes the artist filter the api actually applied: the uuid when the + * response is artist-scoped, null when whole-catalog, and absent on + * pre-v2 deployments that ignore the param — callers requesting an + * artist scope must verify this echo before trusting the numbers. + */ + artist_account_id?: string | null; error?: string; } /** * Fetches the latest per-ISRC play counts + derived valuation band for a - * catalog from the Recoup API (recoupable/chat#1850 data-plumbing contract). + * catalog from the Recoup API (recoupable/chat#1850 data-plumbing contract), + * optionally scoped to one artist account via artist_account_id. * * The endpoint is being rolled out; callers must treat any thrown error as * "no valuation available" and fall back gracefully. @@ -29,9 +37,13 @@ export interface CatalogMeasurementsResponse { export async function getCatalogMeasurements( catalogId: string, accessToken: string, + artistAccountId?: string, ): Promise { const url = new URL(`${getClientApiBaseUrl()}/api/catalogs/measurements`); url.searchParams.set("catalogId", catalogId); + if (artistAccountId) { + url.searchParams.set("artist_account_id", artistAccountId); + } const response = await fetch(url.toString(), { method: "GET", diff --git a/lib/catalog/getCatalogSongs.ts b/lib/catalog/getCatalogSongs.ts index fded952ae..681ed2def 100644 --- a/lib/catalog/getCatalogSongs.ts +++ b/lib/catalog/getCatalogSongs.ts @@ -29,7 +29,7 @@ export async function getCatalogSongs( catalogId: string, pageSize: number = 100, page: number = 1, - artistName?: string + artistName?: string, ): Promise { try { const params = new URLSearchParams({ @@ -49,7 +49,7 @@ export async function getCatalogSongs( headers: { "Content-Type": "application/json", }, - } + }, ); if (!response.ok) { diff --git a/lib/catalog/parseCsvFile.ts b/lib/catalog/parseCsvFile.ts index c4d1b0b27..4d3e0ee44 100644 --- a/lib/catalog/parseCsvFile.ts +++ b/lib/catalog/parseCsvFile.ts @@ -17,7 +17,7 @@ type ParsedRow = Partial> & { */ export function parseCsvFile( text: string, - catalogId: string + catalogId: string, ): CatalogSongInput[] { // Parse CSV using papaparse with optimized configuration const parseResult = Papa.parse(text, { @@ -29,11 +29,11 @@ export function parseCsvFile( // Check for critical parsing errors const criticalErrors = parseResult.errors.filter( - (e) => e.type === "Delimiter" || e.type === "FieldMismatch" + (e) => e.type === "Delimiter" || e.type === "FieldMismatch", ); if (criticalErrors.length > 0) { throw new Error( - `CSV parsing errors: ${criticalErrors.map((e) => e.message).join(", ")}` + `CSV parsing errors: ${criticalErrors.map((e) => e.message).join(", ")}`, ); } diff --git a/lib/catalog/postCatalogSongs.ts b/lib/catalog/postCatalogSongs.ts index 5dd17daec..c730f32ea 100644 --- a/lib/catalog/postCatalogSongs.ts +++ b/lib/catalog/postCatalogSongs.ts @@ -15,16 +15,19 @@ export interface CatalogSongInput { * Adds songs to a catalog by ISRC in batch */ export async function postCatalogSongs( - songs: CatalogSongInput[] + songs: CatalogSongInput[], ): Promise { try { - const response = await fetch(`${getClientApiBaseUrl()}/api/catalogs/songs`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetch( + `${getClientApiBaseUrl()}/api/catalogs/songs`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ songs }), }, - body: JSON.stringify({ songs }), - }); + ); if (!response.ok) { const errorText = await response.text(); diff --git a/lib/catalog/uploadBatchSongs.ts b/lib/catalog/uploadBatchSongs.ts index 23e390bd1..d74758220 100644 --- a/lib/catalog/uploadBatchSongs.ts +++ b/lib/catalog/uploadBatchSongs.ts @@ -5,7 +5,7 @@ const BATCH_SIZE = 1000; export const uploadBatchSongs = async ( songs: CatalogSongInput[], - onProgress?: (current: number, total: number) => void + onProgress?: (current: number, total: number) => void, ) => { let allSongs: CatalogSong[] = []; const batches = []; diff --git a/lib/home/__tests__/findArtistCatalog.test.ts b/lib/home/__tests__/findArtistCatalog.test.ts deleted file mode 100644 index f84eaa9ed..000000000 --- a/lib/home/__tests__/findArtistCatalog.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { findArtistCatalog } from "@/lib/home/findArtistCatalog"; -import type { Catalog } from "@/types/Catalog"; - -const makeCatalog = (id: string, name: string): Catalog => ({ - id, - name, - created_at: "2026-07-06T00:00:00Z", - updated_at: "2026-07-06T00:00:00Z", -}); - -const catalogs = [ - makeCatalog("catalog-1", "Ana Bárbara Catalog"), - makeCatalog("catalog-2", "Del Water Gap Catalog"), -]; - -describe("findArtistCatalog", () => { - it("returns undefined when there are no catalogs", () => { - expect(findArtistCatalog([], "Del Water Gap")).toBeUndefined(); - expect(findArtistCatalog(undefined, "Del Water Gap")).toBeUndefined(); - }); - - it("returns the first catalog when no artist name is given", () => { - expect(findArtistCatalog(catalogs, null)?.id).toBe("catalog-1"); - expect(findArtistCatalog(catalogs, undefined)?.id).toBe("catalog-1"); - }); - - it("prefers the catalog whose name contains the artist name (case-insensitive)", () => { - expect(findArtistCatalog(catalogs, "del water gap")?.id).toBe("catalog-2"); - }); - - it("falls back to the first catalog when no name matches", () => { - expect(findArtistCatalog(catalogs, "September Mourning")?.id).toBe( - "catalog-1", - ); - }); -}); diff --git a/lib/home/__tests__/getValuationHeroState.test.ts b/lib/home/__tests__/getValuationHeroState.test.ts index 91dede244..319ef6521 100644 --- a/lib/home/__tests__/getValuationHeroState.test.ts +++ b/lib/home/__tests__/getValuationHeroState.test.ts @@ -5,22 +5,32 @@ import type { Catalog } from "@/types/Catalog"; const catalog: Catalog = { id: "catalog-1", - name: "Del Water Gap", + name: "Full Roster Catalog", created_at: "2026-07-06T00:00:00Z", updated_at: "2026-07-06T00:00:00Z", }; -const measurements: CatalogMeasurementsResponse = { +const artistAccountId = "b1814076-8e19-4a77-9dea-2ec150e26aaa"; + +const wholeCatalogMeasurements: CatalogMeasurementsResponse = { status: "success", measurements: [ { isrc: "USABC1234567", playcount: 1000 }, { isrc: "USABC1234568", playcount: 2000 }, ], valuation: { low: 959000, mid: 1400000, high: 2000000 }, + artist_account_id: null, +}; + +const artistScopedMeasurements: CatalogMeasurementsResponse = { + status: "success", + measurements: [{ isrc: "USABC1234567", playcount: 1000 }], + valuation: { low: 100000, mid: 146000, high: 205000 }, + artist_account_id: artistAccountId, }; describe("getValuationHeroState", () => { - it("hides the hero while catalogs are still loading", () => { + it("hides the hero while catalogs are still loading / the account has none", () => { expect( getValuationHeroState({ catalog: undefined, @@ -28,69 +38,69 @@ describe("getValuationHeroState", () => { measurements: undefined, measurementsFailed: false, selectedArtistName: null, - artistMatched: undefined, - artistMatchFailed: false, + selectedArtistAccountId: null, }), ).toEqual({ show: false }); }); - it("hides the hero when the account has no catalogs", () => { + it("hides the hero when the catalogs request failed", () => { expect( getValuationHeroState({ catalog: undefined, - catalogsFailed: false, + catalogsFailed: true, measurements: undefined, measurementsFailed: false, selectedArtistName: null, - artistMatched: undefined, - artistMatchFailed: false, + selectedArtistAccountId: null, }), ).toEqual({ show: false }); }); - it("hides the hero when the catalogs request failed", () => { + it("hides the hero when measurements are unavailable (endpoint 404/error)", () => { expect( getValuationHeroState({ - catalog: undefined, - catalogsFailed: true, + catalog, + catalogsFailed: false, measurements: undefined, - measurementsFailed: false, + measurementsFailed: true, selectedArtistName: null, - artistMatched: undefined, - artistMatchFailed: false, + selectedArtistAccountId: null, }), ).toEqual({ show: false }); }); - it("hides the hero when measurements are unavailable (endpoint 404/error)", () => { + it("hides the hero while measurements are still loading", () => { expect( getValuationHeroState({ catalog, catalogsFailed: false, measurements: undefined, - measurementsFailed: true, + measurementsFailed: false, selectedArtistName: null, - artistMatched: undefined, - artistMatchFailed: false, + selectedArtistAccountId: null, }), ).toEqual({ show: false }); }); - it("hides the hero while measurements are still loading", () => { + it("hides the hero when the response has no measurements (empty scope)", () => { expect( getValuationHeroState({ catalog, catalogsFailed: false, - measurements: undefined, + measurements: { + status: "success", + measurements: [], + valuation: { low: 0, mid: 0, high: 0 }, + artist_account_id: null, + }, measurementsFailed: false, selectedArtistName: null, - artistMatched: undefined, - artistMatchFailed: false, + selectedArtistAccountId: null, }), ).toEqual({ show: false }); }); - it("hides the hero when the response has no valuation band", () => { + it("hides the hero when the selected artist has zero measured songs in the catalog", () => { expect( getValuationHeroState({ catalog, @@ -98,11 +108,12 @@ describe("getValuationHeroState", () => { measurements: { status: "success", measurements: [], - } as unknown as CatalogMeasurementsResponse, + valuation: { low: 0, mid: 0, high: 0 }, + artist_account_id: artistAccountId, + }, measurementsFailed: false, - selectedArtistName: null, - artistMatched: undefined, - artistMatchFailed: false, + selectedArtistName: "Elvis Crespo", + selectedArtistAccountId: artistAccountId, }), ).toEqual({ show: false }); }); @@ -112,80 +123,85 @@ describe("getValuationHeroState", () => { getValuationHeroState({ catalog, catalogsFailed: false, - measurements, + measurements: wholeCatalogMeasurements, measurementsFailed: false, selectedArtistName: null, - artistMatched: undefined, - artistMatchFailed: false, + selectedArtistAccountId: null, }), ).toEqual({ show: true, showArtist: false, - catalogName: "Del Water Gap", + catalogName: "Full Roster Catalog", valuation: { low: 959000, mid: 1400000, high: 2000000 }, measuredTrackCount: 2, }); }); - it("hides the hero while the selected artist's catalog match is still resolving", () => { + it("shows the artist-labeled hero when the response echoes the requested artist scope", () => { expect( getValuationHeroState({ catalog, catalogsFailed: false, - measurements, + measurements: artistScopedMeasurements, measurementsFailed: false, - selectedArtistName: "Del Water Gap", - artistMatched: undefined, - artistMatchFailed: false, + selectedArtistName: "Elvis Crespo", + selectedArtistAccountId: artistAccountId, }), - ).toEqual({ show: false }); + ).toEqual({ + show: true, + showArtist: true, + catalogName: "Full Roster Catalog", + valuation: { low: 100000, mid: 146000, high: 205000 }, + measuredTrackCount: 1, + }); }); - it("hides the hero when the selected artist does not match the catalog", () => { + it("hides the hero when an artist is selected but the response is not artist-scoped (pre-v2 api: no echo field)", () => { + // A pre-v2 deployment ignores the unknown artist_account_id param and + // returns whole-catalog numbers with no echo — rendering them under the + // artist's name would be wrong-scoped money. expect( getValuationHeroState({ catalog, catalogsFailed: false, - measurements, + measurements: { + status: "success", + measurements: [ + { isrc: "USABC1234567", playcount: 1000 }, + { isrc: "USABC1234568", playcount: 2000 }, + ], + valuation: { low: 959000, mid: 1400000, high: 2000000 }, + }, measurementsFailed: false, - selectedArtistName: "Ana Bárbara", - artistMatched: false, - artistMatchFailed: false, + selectedArtistName: "Elvis Crespo", + selectedArtistAccountId: artistAccountId, }), ).toEqual({ show: false }); }); - it("hides the hero when the artist match check failed", () => { + it("hides the hero when the echoed scope is a different artist than the one selected", () => { expect( getValuationHeroState({ catalog, catalogsFailed: false, - measurements, + measurements: artistScopedMeasurements, measurementsFailed: false, - selectedArtistName: "Del Water Gap", - artistMatched: undefined, - artistMatchFailed: true, + selectedArtistName: "Apache", + selectedArtistAccountId: "ebae4bb9-e38f-4763-b3c8-ff30e99f5d01", }), ).toEqual({ show: false }); }); - it("shows the artist-labeled hero when the selected artist has songs in the catalog", () => { + it("hides the hero when the echoed scope is null but an artist is selected (stale whole-catalog response)", () => { expect( getValuationHeroState({ catalog, catalogsFailed: false, - measurements, + measurements: wholeCatalogMeasurements, measurementsFailed: false, - selectedArtistName: "Del Water Gap", - artistMatched: true, - artistMatchFailed: false, + selectedArtistName: "Elvis Crespo", + selectedArtistAccountId: artistAccountId, }), - ).toEqual({ - show: true, - showArtist: true, - catalogName: "Del Water Gap", - valuation: { low: 959000, mid: 1400000, high: 2000000 }, - measuredTrackCount: 2, - }); + ).toEqual({ show: false }); }); }); diff --git a/lib/home/__tests__/isArtistCatalogMatch.test.ts b/lib/home/__tests__/isArtistCatalogMatch.test.ts deleted file mode 100644 index 2fe006b44..000000000 --- a/lib/home/__tests__/isArtistCatalogMatch.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isArtistCatalogMatch } from "@/lib/home/isArtistCatalogMatch"; - -const makeSong = (artists: unknown[]) => ({ artists }) as never; - -describe("isArtistCatalogMatch", () => { - it("matches when the catalog name contains the artist name (case-insensitive)", () => { - expect( - isArtistCatalogMatch({ - catalogName: "Del Water Gap Catalog", - artistName: "del water gap", - songs: [], - }), - ).toBe(true); - }); - - it("matches when a song's artists include the artist (object shape)", () => { - expect( - isArtistCatalogMatch({ - catalogName: "[TEST] verification", - artistName: "Del Water Gap", - songs: [makeSong([{ name: "Del Water Gap" }])], - }), - ).toBe(true); - }); - - it("matches when a song's artists include the artist (string shape)", () => { - expect( - isArtistCatalogMatch({ - catalogName: "[TEST] verification", - artistName: "Del Water Gap", - songs: [makeSong(["Del Water Gap"])], - }), - ).toBe(true); - }); - - it("does not match null-linked songs (unenriched catalogs match nobody)", () => { - expect( - isArtistCatalogMatch({ - catalogName: "[TEST] verification", - artistName: "Ana Bárbara", - songs: [makeSong([null]), makeSong([null])], - }), - ).toBe(false); - }); - - it("does not match a different artist", () => { - expect( - isArtistCatalogMatch({ - catalogName: "[TEST] verification", - artistName: "Ana Bárbara", - songs: [makeSong([{ name: "Del Water Gap" }])], - }), - ).toBe(false); - }); - - it("does not match while songs are unknown", () => { - expect( - isArtistCatalogMatch({ - catalogName: "[TEST] verification", - artistName: "Ana Bárbara", - songs: undefined, - }), - ).toBe(false); - }); -}); diff --git a/lib/home/findArtistCatalog.ts b/lib/home/findArtistCatalog.ts deleted file mode 100644 index 0a0beb82e..000000000 --- a/lib/home/findArtistCatalog.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Catalog } from "@/types/Catalog"; - -/** - * Picks which of the account's catalogs the homepage hero should read for - * the selected artist. Catalog names follow the marketing claim convention - * ("{Artist} Catalog"), so a case-insensitive name match wins; otherwise - * the first catalog stands in and the songs-by-artist check in - * useHomeValuation decides whether the pairing is real (recoupable/chat#1850). - */ -export function findArtistCatalog( - catalogs: Catalog[] | undefined, - artistName: string | null | undefined, -): Catalog | undefined { - if (!catalogs?.length) return undefined; - if (!artistName) return catalogs[0]; - - const needle = artistName.toLowerCase(); - return ( - catalogs.find((catalog) => catalog.name.toLowerCase().includes(needle)) ?? - catalogs[0] - ); -} diff --git a/lib/home/getValuationHeroState.ts b/lib/home/getValuationHeroState.ts index 4c0b305b2..904aebcb2 100644 --- a/lib/home/getValuationHeroState.ts +++ b/lib/home/getValuationHeroState.ts @@ -10,8 +10,7 @@ interface GetValuationHeroStateParams { measurements: CatalogMeasurementsResponse | undefined; measurementsFailed: boolean; selectedArtistName: string | null; - artistMatched: boolean | undefined; - artistMatchFailed: boolean; + selectedArtistAccountId: string | null; } export type ValuationHeroState = @@ -27,11 +26,14 @@ export type ValuationHeroState = /** * Decides whether the homepage valuation hero can render, and under which * label. With no artist selected the hero shows the whole catalog's value - * (catalog label). With an artist selected it only shows once the artist is - * confirmed to belong to the catalog (`artistMatched`) — never the - * wrong artist over another catalog's money. Any missing, unresolved, or - * failed input hides the hero so the homepage falls back to the chat - * greeting with zero regression (recoupable/chat#1850). + * (catalog label). With an artist selected the api scopes the read via + * artist_account_id and the hero only renders when the response ECHOES that + * exact scope — a pre-v2 deployment ignores the unknown param and returns + * whole-catalog numbers without the echo, which must never appear under an + * artist's name. An empty measurements array (artist with no measured songs, + * or an unmeasured catalog) and any missing or failed input hide the hero so + * the homepage falls back to the chat greeting with zero regression + * (recoupable/chat#1850). */ export function getValuationHeroState({ catalog, @@ -39,8 +41,7 @@ export function getValuationHeroState({ measurements, measurementsFailed, selectedArtistName, - artistMatched, - artistMatchFailed, + selectedArtistAccountId, }: GetValuationHeroStateParams): ValuationHeroState { if (catalogsFailed || measurementsFailed) return { show: false }; @@ -48,16 +49,20 @@ export function getValuationHeroState({ if (!measurements?.valuation) return { show: false }; - if (selectedArtistName) { - if (artistMatchFailed) return { show: false }; - if (!artistMatched) return { show: false }; + if (!measurements.measurements?.length) return { show: false }; + + if ( + selectedArtistAccountId && + measurements.artist_account_id !== selectedArtistAccountId + ) { + return { show: false }; } return { show: true, - showArtist: !!selectedArtistName, + showArtist: !!selectedArtistAccountId && !!selectedArtistName, catalogName: catalog.name, valuation: measurements.valuation, - measuredTrackCount: measurements.measurements?.length ?? 0, + measuredTrackCount: measurements.measurements.length, }; } diff --git a/lib/home/isArtistCatalogMatch.ts b/lib/home/isArtistCatalogMatch.ts deleted file mode 100644 index 5f735b668..000000000 --- a/lib/home/isArtistCatalogMatch.ts +++ /dev/null @@ -1,37 +0,0 @@ -interface ArtistCatalogMatchParams { - catalogName: string; - artistName: string; - songs: { artists?: unknown[] | null }[] | undefined; -} - -const artistEntryName = (entry: unknown): string | undefined => { - if (typeof entry === "string") return entry; - if (entry && typeof entry === "object" && "name" in entry) { - const { name } = entry as { name?: unknown }; - if (typeof name === "string") return name; - } - return undefined; -}; - -/** - * Whether a catalog verifiably belongs to the selected artist: either the - * catalog is named for the artist (marketing's "{Artist} Catalog" claim - * convention) or one of its songs carries the artist in `song_artists`. - * Songs with null/unlinked artists match nobody — the api's `artistName` - * query filter can't be used for this because it no-ops on unlinked songs - * (LEFT-join since api#681; see recoupable/chat#1850). - */ -export function isArtistCatalogMatch({ - catalogName, - artistName, - songs, -}: ArtistCatalogMatchParams): boolean { - const needle = artistName.toLowerCase(); - if (catalogName.toLowerCase().includes(needle)) return true; - - return (songs ?? []).some((song) => - (song.artists ?? []).some( - (entry) => artistEntryName(entry)?.toLowerCase() === needle, - ), - ); -} From d9f076c4656e82e4ab3bd05f9623066876ebc380 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Jul 2026 07:45:57 -0500 Subject: [PATCH 5/7] refactor(home): hero visibility + count from the whole-scope measured_song_count aggregate Amendment per the 2026-07-07 decision on chat#1850 (docs#267 respec, api#763 amendment, database#42 RPCs): the v2 measurements response now carries whole-scope SQL aggregates and a caller-paginated rows array. - getCatalogMeasurements/useCatalogMeasurements accept a limit param (react-query key includes it); the hero requests limit=1 since it only needs the aggregates, not the rows - getValuationHeroState reads measured_song_count instead of measurements.length: zero -> hide (nothing measured in scope), absent -> hide (pre-v2 shape whose numbers come from a capped read) - echo-field gating unchanged: an artist-selected hero renders only when the response echoes that exact artist_account_id TDD: tests adjusted RED first; full suite 106 tests, tsc 8 pre-existing errors (0 new), prettier clean. Co-Authored-By: Claude Fable 5 --- hooks/useCatalogMeasurements.ts | 9 +++- hooks/useHomeValuation.ts | 8 ++- .../__tests__/getCatalogMeasurements.test.ts | 27 ++++++++++ lib/catalog/getCatalogMeasurements.ts | 19 +++++-- .../__tests__/getValuationHeroState.test.ts | 52 ++++++++++++++++--- lib/home/getValuationHeroState.ts | 13 ++--- 6 files changed, 110 insertions(+), 18 deletions(-) diff --git a/hooks/useCatalogMeasurements.ts b/hooks/useCatalogMeasurements.ts index 78bd27a14..16f8e41c0 100644 --- a/hooks/useCatalogMeasurements.ts +++ b/hooks/useCatalogMeasurements.ts @@ -8,11 +8,17 @@ import { const useCatalogMeasurements = ( catalogId: string | undefined, artistAccountId?: string, + limit?: number, ): UseQueryResult => { const { getAccessToken, authenticated } = usePrivy(); return useQuery({ - queryKey: ["catalog-measurements", catalogId, artistAccountId ?? null], + queryKey: [ + "catalog-measurements", + catalogId, + artistAccountId ?? null, + limit ?? null, + ], queryFn: async () => { const accessToken = await getAccessToken(); if (!accessToken) throw new Error("No access token"); @@ -20,6 +26,7 @@ const useCatalogMeasurements = ( catalogId as string, accessToken, artistAccountId, + limit, ); }, enabled: !!catalogId && authenticated, diff --git a/hooks/useHomeValuation.ts b/hooks/useHomeValuation.ts index 9754c6a63..f60e7c63a 100644 --- a/hooks/useHomeValuation.ts +++ b/hooks/useHomeValuation.ts @@ -33,8 +33,14 @@ const useHomeValuation = (): HomeValuationState => { const { data: catalogsData, isError: catalogsFailed } = useCatalogs(); const catalog = catalogsData?.catalogs?.[0]; + // limit 1: the hero only needs the whole-scope aggregates + // (measured_song_count + valuation), not the measurement rows. const { data: measurements, isError: measurementsFailed } = - useCatalogMeasurements(catalog?.id, selectedArtistAccountId ?? undefined); + useCatalogMeasurements( + catalog?.id, + selectedArtistAccountId ?? undefined, + 1, + ); const state = getValuationHeroState({ catalog, diff --git a/lib/catalog/__tests__/getCatalogMeasurements.test.ts b/lib/catalog/__tests__/getCatalogMeasurements.test.ts index ffa16393f..2b2ef16f0 100644 --- a/lib/catalog/__tests__/getCatalogMeasurements.test.ts +++ b/lib/catalog/__tests__/getCatalogMeasurements.test.ts @@ -90,6 +90,33 @@ describe("getCatalogMeasurements", () => { ); }); + it("passes limit through and returns the whole-scope count", async () => { + const payload = { + status: "success", + measurements: [{ isrc: "USABC1234567", playcount: 1000 }], + measured_song_count: 2679, + valuation: { low: 100, mid: 200, high: 300 }, + artist_account_id: null, + }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(payload), + }) as unknown as typeof fetch; + + const result = await getCatalogMeasurements( + "catalog-1", + accessToken, + undefined, + 1, + ); + + expect(fetch).toHaveBeenCalledWith( + "https://api.recoupable.com/api/catalogs/measurements?catalogId=catalog-1&limit=1", + expect.anything(), + ); + expect(result.measured_song_count).toBe(2679); + }); + it("throws on a non-ok response (endpoint not deployed yet)", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, diff --git a/lib/catalog/getCatalogMeasurements.ts b/lib/catalog/getCatalogMeasurements.ts index 442a5769c..af14af8cd 100644 --- a/lib/catalog/getCatalogMeasurements.ts +++ b/lib/catalog/getCatalogMeasurements.ts @@ -14,7 +14,14 @@ export interface CatalogValuationBand { export interface CatalogMeasurementsResponse { status: string; + /** One page of latest-per-ISRC rows (page/limit window), playcount desc. */ measurements: CatalogSongMeasurement[]; + /** + * Total measured songs in scope — a whole-scope SQL aggregate, independent + * of the requested page. Absent on pre-v2 deployments, whose numbers come + * from a capped read and must not be trusted. + */ + measured_song_count?: number; valuation: CatalogValuationBand; /** * Echoes the artist filter the api actually applied: the uuid when the @@ -27,9 +34,11 @@ export interface CatalogMeasurementsResponse { } /** - * Fetches the latest per-ISRC play counts + derived valuation band for a - * catalog from the Recoup API (recoupable/chat#1850 data-plumbing contract), - * optionally scoped to one artist account via artist_account_id. + * Fetches one page of the latest per-ISRC play counts + the whole-scope + * aggregates (measured_song_count, derived valuation band) for a catalog + * from the Recoup API (recoupable/chat#1850 data-plumbing contract), + * optionally scoped to one artist account via artist_account_id. Pass a + * small limit when only the aggregates matter (the hero uses limit 1). * * The endpoint is being rolled out; callers must treat any thrown error as * "no valuation available" and fall back gracefully. @@ -38,12 +47,16 @@ export async function getCatalogMeasurements( catalogId: string, accessToken: string, artistAccountId?: string, + limit?: number, ): Promise { const url = new URL(`${getClientApiBaseUrl()}/api/catalogs/measurements`); url.searchParams.set("catalogId", catalogId); if (artistAccountId) { url.searchParams.set("artist_account_id", artistAccountId); } + if (limit) { + url.searchParams.set("limit", String(limit)); + } const response = await fetch(url.toString(), { method: "GET", diff --git a/lib/home/__tests__/getValuationHeroState.test.ts b/lib/home/__tests__/getValuationHeroState.test.ts index 319ef6521..f78862875 100644 --- a/lib/home/__tests__/getValuationHeroState.test.ts +++ b/lib/home/__tests__/getValuationHeroState.test.ts @@ -12,12 +12,12 @@ const catalog: Catalog = { const artistAccountId = "b1814076-8e19-4a77-9dea-2ec150e26aaa"; +// The hero requests limit=1 — the rows page is minimal; the whole-scope +// aggregate measured_song_count is the source of truth for visibility/count. const wholeCatalogMeasurements: CatalogMeasurementsResponse = { status: "success", - measurements: [ - { isrc: "USABC1234567", playcount: 1000 }, - { isrc: "USABC1234568", playcount: 2000 }, - ], + measurements: [{ isrc: "USABC1234567", playcount: 2000 }], + measured_song_count: 2679, valuation: { low: 959000, mid: 1400000, high: 2000000 }, artist_account_id: null, }; @@ -25,6 +25,7 @@ const wholeCatalogMeasurements: CatalogMeasurementsResponse = { const artistScopedMeasurements: CatalogMeasurementsResponse = { status: "success", measurements: [{ isrc: "USABC1234567", playcount: 1000 }], + measured_song_count: 322, valuation: { low: 100000, mid: 146000, high: 205000 }, artist_account_id: artistAccountId, }; @@ -82,7 +83,7 @@ describe("getValuationHeroState", () => { ).toEqual({ show: false }); }); - it("hides the hero when the response has no measurements (empty scope)", () => { + it("hides the hero when nothing in scope is measured (measured_song_count 0)", () => { expect( getValuationHeroState({ catalog, @@ -90,6 +91,7 @@ describe("getValuationHeroState", () => { measurements: { status: "success", measurements: [], + measured_song_count: 0, valuation: { low: 0, mid: 0, high: 0 }, artist_account_id: null, }, @@ -100,6 +102,28 @@ describe("getValuationHeroState", () => { ).toEqual({ show: false }); }); + it("hides the hero when the response has no measured_song_count (pre-v2 api shape)", () => { + // The v1 endpoint returns rows but no whole-scope count — its numbers are + // computed over a capped read, so the hero must not trust them. + expect( + getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements: { + status: "success", + measurements: [ + { isrc: "USABC1234567", playcount: 1000 }, + { isrc: "USABC1234568", playcount: 2000 }, + ], + valuation: { low: 959000, mid: 1400000, high: 2000000 }, + }, + measurementsFailed: false, + selectedArtistName: null, + selectedArtistAccountId: null, + }), + ).toEqual({ show: false }); + }); + it("hides the hero when the selected artist has zero measured songs in the catalog", () => { expect( getValuationHeroState({ @@ -108,6 +132,7 @@ describe("getValuationHeroState", () => { measurements: { status: "success", measurements: [], + measured_song_count: 0, valuation: { low: 0, mid: 0, high: 0 }, artist_account_id: artistAccountId, }, @@ -133,10 +158,23 @@ describe("getValuationHeroState", () => { showArtist: false, catalogName: "Full Roster Catalog", valuation: { low: 959000, mid: 1400000, high: 2000000 }, - measuredTrackCount: 2, + measuredTrackCount: 2679, }); }); + it("counts from measured_song_count, not the returned page size", () => { + const result = getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements: { ...wholeCatalogMeasurements, measurements: [] }, + measurementsFailed: false, + selectedArtistName: null, + selectedArtistAccountId: null, + }); + + expect(result).toMatchObject({ show: true, measuredTrackCount: 2679 }); + }); + it("shows the artist-labeled hero when the response echoes the requested artist scope", () => { expect( getValuationHeroState({ @@ -152,7 +190,7 @@ describe("getValuationHeroState", () => { showArtist: true, catalogName: "Full Roster Catalog", valuation: { low: 100000, mid: 146000, high: 205000 }, - measuredTrackCount: 1, + measuredTrackCount: 322, }); }); diff --git a/lib/home/getValuationHeroState.ts b/lib/home/getValuationHeroState.ts index 904aebcb2..68b7d987b 100644 --- a/lib/home/getValuationHeroState.ts +++ b/lib/home/getValuationHeroState.ts @@ -30,10 +30,11 @@ export type ValuationHeroState = * artist_account_id and the hero only renders when the response ECHOES that * exact scope — a pre-v2 deployment ignores the unknown param and returns * whole-catalog numbers without the echo, which must never appear under an - * artist's name. An empty measurements array (artist with no measured songs, - * or an unmeasured catalog) and any missing or failed input hide the hero so - * the homepage falls back to the chat greeting with zero regression - * (recoupable/chat#1850). + * artist's name. Visibility and the track count come from the whole-scope + * measured_song_count aggregate, never the returned page: zero (nothing + * measured in scope) or absent (pre-v2 shape, numbers from a capped read) + * hides the hero, as does any missing or failed input, so the homepage + * falls back to the chat greeting with zero regression (recoupable/chat#1850). */ export function getValuationHeroState({ catalog, @@ -49,7 +50,7 @@ export function getValuationHeroState({ if (!measurements?.valuation) return { show: false }; - if (!measurements.measurements?.length) return { show: false }; + if (!measurements.measured_song_count) return { show: false }; if ( selectedArtistAccountId && @@ -63,6 +64,6 @@ export function getValuationHeroState({ showArtist: !!selectedArtistAccountId && !!selectedArtistName, catalogName: catalog.name, valuation: measurements.valuation, - measuredTrackCount: measurements.measurements.length, + measuredTrackCount: measurements.measured_song_count, }; } From 86e913102aa8645241a00ce3ae97cd7fa026dc45 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Jul 2026 07:57:16 -0500 Subject: [PATCH 6/7] refactor(home): measurements fetch moves to GET /api/catalogs/{catalogId}/measurements REST amendment on chat#1850: path for identity, query for modifiers. getCatalogMeasurements builds the dynamic-segment URL; the query string carries only artist_account_id/limit. No behavior change in the hero. Co-Authored-By: Claude Fable 5 --- lib/catalog/__tests__/getCatalogMeasurements.test.ts | 12 ++++++------ lib/catalog/getCatalogMeasurements.ts | 7 +++++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/catalog/__tests__/getCatalogMeasurements.test.ts b/lib/catalog/__tests__/getCatalogMeasurements.test.ts index 2b2ef16f0..410308cea 100644 --- a/lib/catalog/__tests__/getCatalogMeasurements.test.ts +++ b/lib/catalog/__tests__/getCatalogMeasurements.test.ts @@ -16,7 +16,7 @@ describe("getCatalogMeasurements", () => { ); }); - it("calls GET /api/catalogs/measurements with catalogId and bearer auth", async () => { + it("calls GET /api/catalogs/{catalogId}/measurements with bearer auth", async () => { const payload = { status: "success", measurements: [ @@ -33,7 +33,7 @@ describe("getCatalogMeasurements", () => { const result = await getCatalogMeasurements("catalog-1", accessToken); expect(fetch).toHaveBeenCalledWith( - "https://api.recoupable.com/api/catalogs/measurements?catalogId=catalog-1", + "https://api.recoupable.com/api/catalogs/catalog-1/measurements", expect.objectContaining({ method: "GET", headers: expect.objectContaining({ @@ -63,7 +63,7 @@ describe("getCatalogMeasurements", () => { ); expect(fetch).toHaveBeenCalledWith( - "https://api.recoupable.com/api/catalogs/measurements?catalogId=catalog-1&artist_account_id=b1814076-8e19-4a77-9dea-2ec150e26aaa", + "https://api.recoupable.com/api/catalogs/catalog-1/measurements?artist_account_id=b1814076-8e19-4a77-9dea-2ec150e26aaa", expect.objectContaining({ method: "GET" }), ); expect(result.artist_account_id).toBe( @@ -71,7 +71,7 @@ describe("getCatalogMeasurements", () => { ); }); - it("omits artist_account_id from the query when not provided", async () => { + it("sends no query string when no modifiers are provided", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ @@ -85,7 +85,7 @@ describe("getCatalogMeasurements", () => { await getCatalogMeasurements("catalog-1", accessToken); expect(fetch).toHaveBeenCalledWith( - "https://api.recoupable.com/api/catalogs/measurements?catalogId=catalog-1", + "https://api.recoupable.com/api/catalogs/catalog-1/measurements", expect.anything(), ); }); @@ -111,7 +111,7 @@ describe("getCatalogMeasurements", () => { ); expect(fetch).toHaveBeenCalledWith( - "https://api.recoupable.com/api/catalogs/measurements?catalogId=catalog-1&limit=1", + "https://api.recoupable.com/api/catalogs/catalog-1/measurements?limit=1", expect.anything(), ); expect(result.measured_song_count).toBe(2679); diff --git a/lib/catalog/getCatalogMeasurements.ts b/lib/catalog/getCatalogMeasurements.ts index af14af8cd..2e507a40d 100644 --- a/lib/catalog/getCatalogMeasurements.ts +++ b/lib/catalog/getCatalogMeasurements.ts @@ -49,8 +49,11 @@ export async function getCatalogMeasurements( artistAccountId?: string, limit?: number, ): Promise { - const url = new URL(`${getClientApiBaseUrl()}/api/catalogs/measurements`); - url.searchParams.set("catalogId", catalogId); + // Path for identity, query for the optional modifiers (chat#1850 REST + // decision — mirrors /api/research/albums/{id}/measurements). + const url = new URL( + `${getClientApiBaseUrl()}/api/catalogs/${encodeURIComponent(catalogId)}/measurements`, + ); if (artistAccountId) { url.searchParams.set("artist_account_id", artistAccountId); } From 540a53c4c322860faadd9a96f9156857279e5df2 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Jul 2026 09:07:14 -0500 Subject: [PATCH 7/7] =?UTF-8?q?refactor(home):=20valuation=20hero=20render?= =?UTF-8?q?s=20inside=20ChatGreeting=20=E2=80=94=20no=20chat.tsx=20changes?= =?UTF-8?q?,=20no=20prop=20drilling=20(KISS/OCP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #1852: chat.tsx should not be modified in this PR, and header composition via props is drilling context that providers already own. ChatGreeting — the component chat.tsx already delegates its empty-state header to — becomes the artist header: the artist-scoped valuation hero when measured data exists for the current scope (via useHomeValuation, provider-backed), otherwise the greeting plus a 'Get a free catalog valuation' CTA into the funnel. chat.tsx, NewChatBootstrap and HomePage are byte-identical to main in this PR; every empty-chat surface gets the same single flow for free. Co-Authored-By: Claude Fable 5 --- components/Chat/ChatGreeting.tsx | 52 ++++++++++++++++------ components/Home/HomePage.tsx | 18 +------- components/VercelChat/NewChatBootstrap.tsx | 4 -- components/VercelChat/chat.tsx | 19 ++------ lib/consts.ts | 1 + 5 files changed, 45 insertions(+), 49 deletions(-) diff --git a/components/Chat/ChatGreeting.tsx b/components/Chat/ChatGreeting.tsx index f53d04889..dbe43911d 100644 --- a/components/Chat/ChatGreeting.tsx +++ b/components/Chat/ChatGreeting.tsx @@ -1,39 +1,55 @@ import { useArtistProvider } from "@/providers/ArtistProvider"; import ImageWithFallback from "@/components/ImageWithFallback"; +import ValuationHero from "@/components/Home/ValuationHero"; +import useHomeValuation from "@/hooks/useHomeValuation"; +import { VALUATION_URL } from "@/lib/consts"; /** - * Displays a simple greeting message asking about the selected artist - * Shows "Ask me about [Avatar] [Selected Artist]" or fallback text + * The empty chat's artist header (recoupable/chat#1850): the artist-scoped + * catalog valuation hero when the account has measured data for the current + * scope, otherwise the "Ask me about" greeting plus a CTA into the valuation + * funnel. All context arrives via provider-backed hooks — no props beyond + * the fade flag the chat empty state already owned, so the chat component + * needs no knowledge of what this header shows. */ export function ChatGreeting({ isVisible }: { isVisible: boolean }) { const { selectedArtist } = useArtistProvider(); + const valuation = useHomeValuation(); const artistName = selectedArtist?.name || ""; const artistImage = selectedArtist?.image || ""; const isArtistSelected = !!selectedArtist; + const fadeBase = "transition-opacity duration-700 ease-out"; + const fadeClass = `${fadeBase} ${isVisible ? "opacity-100" : "opacity-0"}`; + + if (valuation.show) { + return ( +
+ +
+ ); + } + const textStyle = ` font-heading text-[20px] sm:text-[24px] - lg:text-[28px] + lg:text-[28px] leading-[1.4] sm:leading-[1.3] - lg:leading-[1.3] + lg:leading-[1.3] tracking-[-0.02em] font-medium `; - const fadeBase = "transition-opacity duration-700 ease-out"; - const fadeStart = "opacity-0"; - const fadeEnd = "opacity-100"; - return (
Ask me about{" "} @@ -44,6 +60,16 @@ export function ChatGreeting({ isVisible }: { isVisible: boolean }) { )} {isArtistSelected ? artistName : "your roster"} +
); } diff --git a/components/Home/HomePage.tsx b/components/Home/HomePage.tsx index b926d4108..9833bd99f 100644 --- a/components/Home/HomePage.tsx +++ b/components/Home/HomePage.tsx @@ -2,14 +2,11 @@ import { useMiniKit } from "@coinbase/onchainkit/minikit"; import NewChatBootstrap from "../VercelChat/NewChatBootstrap"; -import ValuationHero from "./ValuationHero"; -import useHomeValuation from "@/hooks/useHomeValuation"; import { useEffect } from "react"; import { UIMessage } from "ai"; const HomePage = ({ initialMessages }: { initialMessages?: UIMessage[] }) => { const { setFrameReady, isFrameReady } = useMiniKit(); - const valuation = useHomeValuation(); useEffect(() => { if (!isFrameReady) { @@ -19,20 +16,7 @@ const HomePage = ({ initialMessages }: { initialMessages?: UIMessage[] }) => { return (
- {valuation.show && ( -
- -
- )} - +
); }; diff --git a/components/VercelChat/NewChatBootstrap.tsx b/components/VercelChat/NewChatBootstrap.tsx index 7b64fd972..4d00d8b2b 100644 --- a/components/VercelChat/NewChatBootstrap.tsx +++ b/components/VercelChat/NewChatBootstrap.tsx @@ -9,8 +9,6 @@ import { generateUUID } from "@/lib/generateUUID"; interface NewChatBootstrapProps { initialMessages?: UIMessage[]; - /** Hide the empty-state greeting when the homepage renders its own hero (chat#1850). */ - hideGreeting?: boolean; } /** @@ -27,7 +25,6 @@ interface NewChatBootstrapProps { */ export default function NewChatBootstrap({ initialMessages, - hideGreeting, }: NewChatBootstrapProps) { const state = useNewChatBootstrap(); // Stable client id so the instance (and anything typed into it) @@ -52,7 +49,6 @@ export default function NewChatBootstrap({ workflowChatId={state.status === "ready" ? state.chatId : undefined} workspaceStatus={workspaceStatus} initialMessages={initialMessages} - hideGreeting={hideGreeting} /> ); } diff --git a/components/VercelChat/chat.tsx b/components/VercelChat/chat.tsx index be48f922e..204abd1f7 100644 --- a/components/VercelChat/chat.tsx +++ b/components/VercelChat/chat.tsx @@ -42,12 +42,6 @@ interface ChatProps { */ workspaceStatus?: WorkspaceStatus; initialMessages?: UIMessage[]; - /** - * Hide the empty-state greeting when the homepage renders the valuation - * hero above the chat area instead (recoupable/chat#1850). Defaults to - * false so every existing mount keeps the greeting. - */ - hideGreeting?: boolean; } export function Chat({ @@ -56,7 +50,6 @@ export function Chat({ workflowChatId, workspaceStatus, initialMessages, - hideGreeting, }: ChatProps) { const { selectedOrgId } = useOrganization(); const providerKey = `${id}-${selectedOrgId ?? "personal"}`; @@ -70,7 +63,7 @@ export function Chat({ workspaceStatus={workspaceStatus} initialMessages={initialMessages} > - + ); } @@ -78,11 +71,9 @@ export function Chat({ // Inner component that uses the context function ChatContentMemoized({ sessionId, - hideGreeting, }: { id: string; sessionId?: string; - hideGreeting?: boolean; }) { const { messages, status, isLoading, hasError } = useVercelChatContext(); const { chatId: routeChatId } = useParams<{ chatId?: string }>(); @@ -120,7 +111,7 @@ function ChatContentMemoized({ "px-4 md:px-0 pb-4 flex flex-col h-full items-center w-full relative", { "justify-between": messages.length > 0, - }, + } )} {...getRootProps()} > @@ -133,7 +124,7 @@ function ChatContentMemoized({ {/* Centered greeting and chat input */}
- {!hideGreeting && } +
@@ -155,8 +146,6 @@ function ChatContentMemoized({ const ChatContent = memo(ChatContentMemoized, (prevProps, nextProps) => { return ( - prevProps.id === nextProps.id && - prevProps.sessionId === nextProps.sessionId && - prevProps.hideGreeting === nextProps.hideGreeting + prevProps.id === nextProps.id && prevProps.sessionId === nextProps.sessionId ); }); diff --git a/lib/consts.ts b/lib/consts.ts index aa3c78817..374bc150b 100644 --- a/lib/consts.ts +++ b/lib/consts.ts @@ -6,6 +6,7 @@ export const NEW_API_BASE_URL = IS_PROD : "https://test-recoup-api.vercel.app"; export const APP_BASE_URL = "https://chat.recoupable.dev"; export const DOCS_BASE_URL = "https://docs.recoupable.dev"; +export const VALUATION_URL = "https://recoupable.dev/valuation"; export const API_PUBLIC_BASE_URL = "https://api.recoupable.dev"; export const API_OVERRIDE_STORAGE_KEY = "recoup_api_override"; export const ACCOUNT_OVERRIDE_STORAGE_KEY = "recoup_account_override";