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"} +
+ + Get a free catalog valuation → + +
); } 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/hooks/useCatalogMeasurements.ts b/hooks/useCatalogMeasurements.ts new file mode 100644 index 000000000..16f8e41c0 --- /dev/null +++ b/hooks/useCatalogMeasurements.ts @@ -0,0 +1,41 @@ +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, + artistAccountId?: string, + limit?: number, +): UseQueryResult => { + const { getAccessToken, authenticated } = usePrivy(); + + return useQuery({ + queryKey: [ + "catalog-measurements", + catalogId, + artistAccountId ?? null, + limit ?? null, + ], + queryFn: async () => { + const accessToken = await getAccessToken(); + if (!accessToken) throw new Error("No access token"); + return getCatalogMeasurements( + catalogId as string, + accessToken, + artistAccountId, + limit, + ); + }, + 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..f60e7c63a --- /dev/null +++ b/hooks/useHomeValuation.ts @@ -0,0 +1,67 @@ +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. 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 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, + 1, + ); + + const state = getValuationHeroState({ + catalog, + catalogsFailed, + measurements, + measurementsFailed, + selectedArtistName, + selectedArtistAccountId, + }); + + if (!state.show) return { show: false }; + + return { + show: true, + artistName: state.showArtist + ? selectedArtistName || state.catalogName + : state.catalogName, + artistImage: state.showArtist ? 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..410308cea --- /dev/null +++ b/lib/catalog/__tests__/getCatalogMeasurements.test.ts @@ -0,0 +1,145 @@ +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/{catalogId}/measurements with 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/catalog-1/measurements", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + 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/catalog-1/measurements?artist_account_id=b1814076-8e19-4a77-9dea-2ec150e26aaa", + expect.objectContaining({ method: "GET" }), + ); + expect(result.artist_account_id).toBe( + "b1814076-8e19-4a77-9dea-2ec150e26aaa", + ); + }); + + it("sends no query string when no modifiers are 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/catalog-1/measurements", + expect.anything(), + ); + }); + + 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/catalog-1/measurements?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, + 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/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/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..2e507a40d --- /dev/null +++ b/lib/catalog/getCatalogMeasurements.ts @@ -0,0 +1,83 @@ +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; + /** 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 + * 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 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. + */ +export async function getCatalogMeasurements( + catalogId: string, + accessToken: string, + artistAccountId?: string, + limit?: number, +): Promise { + // 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); + } + if (limit) { + url.searchParams.set("limit", String(limit)); + } + + 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/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/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"; diff --git a/lib/home/__tests__/getValuationHeroState.test.ts b/lib/home/__tests__/getValuationHeroState.test.ts new file mode 100644 index 000000000..f78862875 --- /dev/null +++ b/lib/home/__tests__/getValuationHeroState.test.ts @@ -0,0 +1,245 @@ +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: "Full Roster Catalog", + created_at: "2026-07-06T00:00:00Z", + updated_at: "2026-07-06T00:00:00Z", +}; + +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: 2000 }], + measured_song_count: 2679, + valuation: { low: 959000, mid: 1400000, high: 2000000 }, + artist_account_id: null, +}; + +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, +}; + +describe("getValuationHeroState", () => { + it("hides the hero while catalogs are still loading / the account has none", () => { + expect( + getValuationHeroState({ + catalog: undefined, + catalogsFailed: false, + measurements: undefined, + measurementsFailed: false, + selectedArtistName: null, + selectedArtistAccountId: null, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero when the catalogs request failed", () => { + expect( + getValuationHeroState({ + catalog: undefined, + catalogsFailed: true, + measurements: undefined, + measurementsFailed: false, + selectedArtistName: null, + selectedArtistAccountId: null, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero when measurements are unavailable (endpoint 404/error)", () => { + expect( + getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements: undefined, + measurementsFailed: true, + selectedArtistName: null, + selectedArtistAccountId: null, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero while measurements are still loading", () => { + expect( + getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements: undefined, + measurementsFailed: false, + selectedArtistName: null, + selectedArtistAccountId: null, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero when nothing in scope is measured (measured_song_count 0)", () => { + expect( + getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements: { + status: "success", + measurements: [], + measured_song_count: 0, + valuation: { low: 0, mid: 0, high: 0 }, + artist_account_id: null, + }, + measurementsFailed: false, + selectedArtistName: null, + selectedArtistAccountId: null, + }), + ).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({ + catalog, + catalogsFailed: false, + measurements: { + status: "success", + measurements: [], + measured_song_count: 0, + valuation: { low: 0, mid: 0, high: 0 }, + artist_account_id: artistAccountId, + }, + measurementsFailed: false, + selectedArtistName: "Elvis Crespo", + selectedArtistAccountId: artistAccountId, + }), + ).toEqual({ show: false }); + }); + + it("shows the whole-catalog valuation (catalog label) when no artist is selected", () => { + expect( + getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements: wholeCatalogMeasurements, + measurementsFailed: false, + selectedArtistName: null, + selectedArtistAccountId: null, + }), + ).toEqual({ + show: true, + showArtist: false, + catalogName: "Full Roster Catalog", + valuation: { low: 959000, mid: 1400000, high: 2000000 }, + 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({ + catalog, + catalogsFailed: false, + measurements: artistScopedMeasurements, + measurementsFailed: false, + selectedArtistName: "Elvis Crespo", + selectedArtistAccountId: artistAccountId, + }), + ).toEqual({ + show: true, + showArtist: true, + catalogName: "Full Roster Catalog", + valuation: { low: 100000, mid: 146000, high: 205000 }, + measuredTrackCount: 322, + }); + }); + + 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: { + status: "success", + measurements: [ + { isrc: "USABC1234567", playcount: 1000 }, + { isrc: "USABC1234568", playcount: 2000 }, + ], + valuation: { low: 959000, mid: 1400000, high: 2000000 }, + }, + measurementsFailed: false, + selectedArtistName: "Elvis Crespo", + selectedArtistAccountId: artistAccountId, + }), + ).toEqual({ show: false }); + }); + + it("hides the hero when the echoed scope is a different artist than the one selected", () => { + expect( + getValuationHeroState({ + catalog, + catalogsFailed: false, + measurements: artistScopedMeasurements, + measurementsFailed: false, + selectedArtistName: "Apache", + selectedArtistAccountId: "ebae4bb9-e38f-4763-b3c8-ff30e99f5d01", + }), + ).toEqual({ show: false }); + }); + + 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: wholeCatalogMeasurements, + measurementsFailed: false, + selectedArtistName: "Elvis Crespo", + selectedArtistAccountId: artistAccountId, + }), + ).toEqual({ show: false }); + }); +}); diff --git a/lib/home/getValuationHeroState.ts b/lib/home/getValuationHeroState.ts new file mode 100644 index 000000000..68b7d987b --- /dev/null +++ b/lib/home/getValuationHeroState.ts @@ -0,0 +1,69 @@ +import type { + CatalogMeasurementsResponse, + CatalogValuationBand, +} from "@/lib/catalog/getCatalogMeasurements"; +import type { Catalog } from "@/types/Catalog"; + +interface GetValuationHeroStateParams { + catalog: Catalog | undefined; + catalogsFailed: boolean; + measurements: CatalogMeasurementsResponse | undefined; + measurementsFailed: boolean; + selectedArtistName: string | null; + selectedArtistAccountId: string | null; +} + +export type ValuationHeroState = + | { show: false } + | { + show: true; + showArtist: boolean; + catalogName: string; + valuation: CatalogValuationBand; + measuredTrackCount: number; + }; + +/** + * 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 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. 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, + catalogsFailed, + measurements, + measurementsFailed, + selectedArtistName, + selectedArtistAccountId, +}: GetValuationHeroStateParams): ValuationHeroState { + if (catalogsFailed || measurementsFailed) return { show: false }; + + if (!catalog) return { show: false }; + + if (!measurements?.valuation) return { show: false }; + + if (!measurements.measured_song_count) return { show: false }; + + if ( + selectedArtistAccountId && + measurements.artist_account_id !== selectedArtistAccountId + ) { + return { show: false }; + } + + return { + show: true, + showArtist: !!selectedArtistAccountId && !!selectedArtistName, + catalogName: catalog.name, + valuation: measurements.valuation, + measuredTrackCount: measurements.measured_song_count, + }; +}