Skip to content
52 changes: 39 additions & 13 deletions components/Chat/ChatGreeting.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`mb-6 mt-4 py-3 ${fadeClass}`}>
<ValuationHero
artistName={valuation.artistName}
artistImage={valuation.artistImage}
valuation={valuation.valuation}
measuredTrackCount={valuation.measuredTrackCount}
/>
</div>
);
}

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 (
<div
className={`
${textStyle} mb-6 mt-4 py-3 ${fadeBase}
${isVisible ? fadeEnd : fadeStart}
text-center w-full
`}
className={`${textStyle} mb-6 mt-4 py-3 ${fadeClass} text-center w-full`}
>
<span className="text-foreground font-medium inline-flex items-center gap-3 flex-wrap justify-center">
Ask me about{" "}
Expand All @@ -44,6 +60,16 @@ export function ChatGreeting({ isVisible }: { isVisible: boolean }) {
)}
{isArtistSelected ? artistName : "your roster"}
</span>
<div className="mt-4">
<a
href={VALUATION_URL}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-card px-4 py-2 text-sm font-normal text-muted-foreground shadow-[0px_0px_0px_1px_var(--border)] transition-colors hover:text-foreground"
>
Get a free catalog valuation &rarr;
</a>
</div>
</div>
);
}
Expand Down
53 changes: 53 additions & 0 deletions components/Home/ValuationHero.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<section
aria-label="Estimated catalog value"
className="w-full rounded-xl bg-card p-6 shadow-[0px_0px_0px_1px_var(--border),0px_2px_4px_rgba(0,0,0,0.04)] dark:shadow-[0px_0px_0px_1px_var(--border),0px_2px_4px_rgba(0,0,0,0.2)]"
>
<div className="flex items-center gap-3">
{artistImage && (
<span className="inline-block size-10 shrink-0 overflow-hidden rounded-full ring-1 ring-foreground">
<ImageWithFallback src={artistImage} />
</span>
)}
<div className="flex flex-col">
<span className="font-heading text-base font-semibold text-foreground">
{artistName}
</span>
<span className="text-xs uppercase tracking-[0.05em] text-muted-foreground">
Estimated catalog value
</span>
</div>
</div>
<p className="mt-4 font-heading text-5xl font-semibold tracking-tight text-foreground tabular-nums sm:text-6xl">
{formatValuationAmount(valuation.mid)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
Range {formatValuationAmount(valuation.low)} to{" "}
{formatValuationAmount(valuation.high)} &middot; {measuredTrackCount}{" "}
{measuredTrackCount === 1 ? "track" : "tracks"} measured
</p>
</section>
);

export default ValuationHero;
41 changes: 41 additions & 0 deletions hooks/useCatalogMeasurements.ts
Original file line number Diff line number Diff line change
@@ -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<CatalogMeasurementsResponse> => {
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;
67 changes: 67 additions & 0 deletions hooks/useHomeValuation.ts
Original file line number Diff line number Diff line change
@@ -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];
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

// 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;
20 changes: 20 additions & 0 deletions lib/catalog/__tests__/formatValuationAmount.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading