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/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,
+ };
+}