diff --git a/app/api/spotify/artist/[id]/route.ts b/app/api/spotify/artist/[id]/route.ts new file mode 100644 index 0000000..548842a --- /dev/null +++ b/app/api/spotify/artist/[id]/route.ts @@ -0,0 +1,36 @@ +import { siteConfig } from "@/lib/config"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * GET /api/spotify/artist/[id] + * + * Fetches a single Spotify artist by ID via the Recoup API proxy. + * Returns { id, name, image, genre, followers } or 404. + */ +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + if (!id) { + return NextResponse.json({ error: "missing id" }, { status: 400 }); + } + + try { + const res = await fetch(`${siteConfig.apiUrl}/spotify/artist/${id}`); + if (!res.ok) { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + + const a = await res.json(); + return NextResponse.json({ + id: a.id ?? id, + name: a.name ?? "Unknown Artist", + image: a.images?.[0]?.url ?? null, + genre: a.genres?.[0] ?? null, + followers: a.followers?.total ?? 0, + }); + } catch { + return NextResponse.json({ error: "fetch failed" }, { status: 502 }); + } +} diff --git a/app/valuation/[artistId]/page.tsx b/app/valuation/[artistId]/page.tsx new file mode 100644 index 0000000..9e3dcf7 --- /dev/null +++ b/app/valuation/[artistId]/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import { fetchArtistServer } from "@/lib/spotify/fetchArtistServer"; +import { CatalogValuation } from "@/components/valuation/CatalogValuation"; + +type Props = { + params: Promise<{ artistId: string }>; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { artistId } = await params; + const artist = await fetchArtistServer(artistId); + const name = artist?.name ?? "this artist"; + + return { + title: `What's ${name}'s Catalog Worth? | Recoup`, + description: `Get a directional valuation of ${name}'s recorded catalog from live Spotify play counts — measured, not guessed.`, + openGraph: { + title: `What's ${name}'s Catalog Worth?`, + description: `Run a live catalog valuation for ${name} — one click, no uploads, no statements.`, + ...(artist?.image ? { images: [{ url: artist.image, width: 640, height: 640 }] } : {}), + }, + twitter: { + card: "summary_large_image", + title: `What's ${name}'s Catalog Worth?`, + description: `Run a live catalog valuation for ${name} — one click, no uploads, no statements.`, + }, + }; +} + +export default async function ArtistValuationPage({ params }: Props) { + const { artistId } = await params; + + return ( +
+
+ + + Live catalog valuation + +

+ What's your catalog worth? +

+

+ Pick your artist. We measure live play counts across your entire + catalog and turn them into a valuation band — one click, no uploads, + no statements. +

+ +
+
+ ); +} diff --git a/components/valuation/CatalogValuation.tsx b/components/valuation/CatalogValuation.tsx index d0d10e1..a94b383 100644 --- a/components/valuation/CatalogValuation.tsx +++ b/components/valuation/CatalogValuation.tsx @@ -5,13 +5,18 @@ import { ArtistSearchBox } from "@/components/search/ArtistSearchBox"; import { ValuationResult } from "@/components/valuation/ValuationResult"; import { toArtist } from "@/lib/valuation/toArtist"; +type CatalogValuationProps = { + /** Spotify artist ID from a shareable URL — auto-selects and runs. */ + initialArtistId?: string; +}; + /** * The one-click catalog valuation flow: search → run → shareable result card. * Adapts the shared ArtistSearchBox to the valuation domain inline — Spotify→ * Artist mapping, the clear-while-running guard, and error chrome (chat#1814). */ -export function CatalogValuation() { - const v = useCatalogValuation(); +export function CatalogValuation({ initialArtistId }: CatalogValuationProps = {}) { + const v = useCatalogValuation(initialArtistId); const running = v.phase === "running"; return ( diff --git a/components/valuation/ShareValuation.tsx b/components/valuation/ShareValuation.tsx new file mode 100644 index 0000000..9774bee --- /dev/null +++ b/components/valuation/ShareValuation.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { formatUsd } from "@/lib/valuation/formatUsd"; + +type ShareValuationProps = { + artistName: string | null; + artistId: string | null; + centralValue: number; +}; + +function CopyIcon() { + return ( + + + + + ); +} + +function CheckIcon() { + return ( + + + + ); +} + +function XIcon() { + return ( + + + + ); +} + +function LinkedInIcon() { + return ( + + + + ); +} + +export function ShareValuation({ artistName, artistId, centralValue }: ShareValuationProps) { + const [copied, setCopied] = useState(false); + + const url = artistId + ? `https://recoupable.dev/valuation/${artistId}` + : "https://recoupable.dev/valuation"; + const value = formatUsd(centralValue); + const name = artistName ?? "My artist"; + + const tweetText = `${name} catalog just got valued at ${value}. What's yours worth?\n\n${url}\n\n#recoup`; + const tweetUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}`; + const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`; + + async function copyLink() { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback — noop + } + } + + const btnClass = + "inline-flex items-center gap-2 px-4 py-2.5 rounded-lg text-[12px] font-pixel uppercase tracking-[0.12em] text-(--foreground)/45 transition-all duration-200 hover:text-(--foreground)/70 hover:bg-(--foreground)/[0.04]"; + + return ( +
+ + + + Share + + + + Share + +
+ ); +} diff --git a/components/valuation/ValuationResult.tsx b/components/valuation/ValuationResult.tsx index 5fe6598..b8e1dcf 100644 --- a/components/valuation/ValuationResult.tsx +++ b/components/valuation/ValuationResult.tsx @@ -3,6 +3,7 @@ import { ArtistHeader } from "@/components/valuation/ArtistHeader"; import { ValuationStats } from "@/components/valuation/ValuationStats"; import { MeasuredCatalog } from "@/components/valuation/MeasuredCatalog"; import { GetFullReportCta } from "@/components/valuation/GetFullReportCta"; +import { ShareValuation } from "@/components/valuation/ShareValuation"; import { formatUsd } from "@/lib/valuation/formatUsd"; type ValuationResultProps = { @@ -49,6 +50,7 @@ export function ValuationResult({ artist, result, catalogAlbums }: ValuationResu totalStreams={result.totalStreams} /> + ); } diff --git a/hooks/useCatalogValuation.ts b/hooks/useCatalogValuation.ts index 2928639..6e2d444 100644 --- a/hooks/useCatalogValuation.ts +++ b/hooks/useCatalogValuation.ts @@ -9,6 +9,8 @@ import type { } from "@/components/valuation/types"; import { runValuationFlow } from "@/lib/valuation/runValuationFlow"; import { captureRunLead } from "@/lib/valuation/captureRunLead"; +import { fetchSpotifyArtist } from "@/lib/spotify/fetchArtist"; +import { toArtist } from "@/lib/valuation/toArtist"; type Phase = "idle" | "running" | "done" | "error"; @@ -28,10 +30,16 @@ export type CatalogValuationState = { * Drives the catalog valuation behind the Privy sign-in gate (chat#1798). The * run trigger opens Privy when signed out and, on login, auto-fires the run for * the **originally** selected artist. Render inside `PrivyProvider`. + * + * When `initialArtistId` is provided (shareable URL), auto-fetches the artist + * and queues the valuation run on mount. */ -export function useCatalogValuation(): CatalogValuationState { +export function useCatalogValuation( + initialArtistId?: string, +): CatalogValuationState { const { authenticated, login, getAccessToken, user } = usePrivy(); const [picked, setPicked] = useState(null); + const initialFetched = useRef(false); const [catalogAlbums, setCatalogAlbums] = useState([]); const [phase, setPhase] = useState("idle"); @@ -83,6 +91,26 @@ export function useCatalogValuation(): CatalogValuationState { // eslint-disable-next-line react-hooks/exhaustive-deps }, [authenticated]); + // Shareable URL: fetch artist by Spotify ID and auto-trigger the run. + useEffect(() => { + if (!initialArtistId || initialFetched.current) return; + initialFetched.current = true; + void (async () => { + const spotify = await fetchSpotifyArtist(initialArtistId); + if (!spotify) return; + const artist = toArtist(spotify); + setPicked(artist); + // If already authenticated, run immediately; otherwise queue for login. + if (authenticated) { + void doRun(artist); + } else { + pendingRun.current = artist; + login(); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialArtistId]); + return { picked, catalogAlbums, diff --git a/lib/spotify/fetchArtist.ts b/lib/spotify/fetchArtist.ts new file mode 100644 index 0000000..a7d6f79 --- /dev/null +++ b/lib/spotify/fetchArtist.ts @@ -0,0 +1,17 @@ +import type { SpotifyArtist } from "@/lib/spotify/types"; + +/** + * Fetch a single Spotify artist by ID via the internal API proxy. + * Returns null if not found or on error. + */ +export async function fetchSpotifyArtist( + id: string, +): Promise { + try { + const res = await fetch(`/api/spotify/artist/${id}`); + if (!res.ok) return null; + return (await res.json()) as SpotifyArtist; + } catch { + return null; + } +} diff --git a/lib/spotify/fetchArtistServer.ts b/lib/spotify/fetchArtistServer.ts new file mode 100644 index 0000000..7fd5522 --- /dev/null +++ b/lib/spotify/fetchArtistServer.ts @@ -0,0 +1,25 @@ +import { siteConfig } from "@/lib/config"; + +type ArtistInfo = { name: string; image: string | null }; + +/** + * Server-side fetch of a Spotify artist for metadata generation. + * Calls the Recoup API directly (no internal proxy needed at build time). + */ +export async function fetchArtistServer( + id: string, +): Promise { + try { + const res = await fetch(`${siteConfig.apiUrl}/spotify/artist/${id}`, { + next: { revalidate: 86400 }, // cache 24h + }); + if (!res.ok) return null; + const data = await res.json(); + return { + name: data.name ?? "Unknown Artist", + image: data.images?.[0]?.url ?? null, + }; + } catch { + return null; + } +}