import type { Metadata } from "next"; import { notFound, redirect } from "next/navigation"; import { cache } from "react"; import { PortableText } from "@portabletext/react"; import { defineQuery } from "next-sanity"; import { sanity } from "@/lib/sanity"; import { portableTextComponents } from "@/lib/portableTextComponents"; import { ReleaseCover } from "@/components/release/ReleaseCover"; import { AnimatedText } from "@/components/AnimatedText"; import { urlFor } from "@/lib/sanityImage"; import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants"; import { ArrowLink } from "@/components/ArrowLink"; import { BookletLink } from "@/components/release/BookletLink"; import { Breadcrumb } from "@/components/Breadcrumb"; import { formatYears, type ArtistCardData, type ComposerCardData } from "@/components/artist/types"; import { ArtistCardCompact } from "@/components/artist/ArtistCardCompact"; import { Tracklist, type TrackData } from "@/components/release/Tracklist"; import { DetailTable } from "@/components/release/DetailTable"; import type { SanityImageSource } from "@sanity/image-url"; import { getProductByEan, getProductByCatalogNo } from "@/lib/medusa"; import { matchVariants } from "@/lib/variants"; import { FormatSelector } from "@/components/release/FormatSelector"; import { parseDuration, toISO8601Duration } from "@/lib/duration"; export const dynamicParams = true; export const revalidate = 86400; type Review = { quote?: string; author?: string; }; type Credit = { role?: string; name?: string; }; type EquipmentItem = { type?: string; name?: string; }; type ReleaseDetail = { name?: string; albumArtist?: string; label?: string; catalogNo?: string; upc?: string; slug?: string; releaseDate?: string; shortDescription?: string; description?: any; albumCover?: SanityImageSource; format?: string; genre?: string[]; spotifyUrl?: string; appleMusicUrl?: string; tidalUrl?: string; artists?: ArtistCardData[]; composers?: ComposerCardData[]; arrangers?: ComposerCardData[]; tracks?: TrackData[]; reviews?: Review[]; credits?: Credit[]; recordingDate?: string; recordingLocation?: string; recordingFormat?: string; masteringFormat?: string; bookletPdfUrl?: string; equipment?: EquipmentItem[]; }; const RELEASE_DETAIL_QUERY = defineQuery(` *[_type == "release" && slug.current == $slug][0]{ name, albumArtist, label, catalogNo, upc, "slug": slug.current, releaseDate, shortDescription, description, albumCover, format, genre[], spotifyUrl, appleMusicUrl, tidalUrl, "artists": artists[]->{ _id, name, role, "slug": slug.current, image }, "composers": tracks[].work->composer->{ _id, name, sortKey, "slug": slug.current, birthYear, deathYear, image }, "arrangers": tracks[].work->arranger->{ _id, name, sortKey, "slug": slug.current, birthYear, deathYear, image }, tracks[]{ "workId": work->_id, "workTitle": work->title, "composerName": work->composer->name, "arrangerName": work->arranger->name, movement, displayTitle, duration, artist, "previewMp3Url": previewMp3.asset->url }, reviews[]{ quote, author }, credits[]{ role, name }, recordingDate, recordingLocation, recordingFormat, masteringFormat, "bookletPdfUrl": bookletPdf.asset->url, equipment[]{ type, name } } `); const getRelease = cache(async (slug: string) => { try { const release = await sanity.fetch(RELEASE_DETAIL_QUERY, { slug }); return release; } catch (error) { console.error("Failed to fetch release:", error); return null; } }); const RELEASE_SLUGS_QUERY = defineQuery( `*[_type == "release" && defined(slug.current)]{ "slug": slug.current }`, ); export async function generateStaticParams() { const slugs = await sanity.fetch>(RELEASE_SLUGS_QUERY); return slugs.map((r) => ({ slug: r.slug })); } export async function generateMetadata({ params, }: { params: Promise<{ slug: string }>; }): Promise { const { slug: rawSlug } = await params; const slug = rawSlug.toLowerCase(); const release = await getRelease(slug); if (!release) notFound(); const description = release.shortDescription ?? `Listen to ${release.name}${release.albumArtist ? ` by ${release.albumArtist}` : ""} on TRPTK.`; const ogImage = release.albumCover ? urlFor(release.albumCover).width(1200).height(1200).url() : undefined; return { title: `${release.albumArtist ? `${release.albumArtist} • ` : ""}${release.name}`, description, alternates: { canonical: `/release/${slug}` }, openGraph: { title: release.name, description: `${release.albumArtist || "TRPTK"}${release.label ? ` - ${release.label}` : ""}`, type: "music.album", ...(ogImage && { images: [ { url: ogImage, width: 1200, height: 1200, alt: `${release.name} by ${release.albumArtist}`, }, ], }), }, twitter: { card: "summary_large_image", title: release.name, description, ...(ogImage && { images: [ogImage] }), }, }; } function formatReleaseDate(dateString?: string) { if (!dateString) return null; return new Intl.DateTimeFormat("en-US", { day: "numeric", month: "long", year: "numeric", }).format(new Date(dateString)); } function dedupeById(items: (T | null)[]): T[] { const seen = new Set(); return items.filter((item): item is T => { if (!item || !item._id || seen.has(item._id)) return false; seen.add(item._id); return true; }); } export default async function ReleasePage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; if (!slug) notFound(); const normalizedSlug = slug.toLowerCase(); if (slug !== normalizedSlug) { redirect(`/release/${normalizedSlug}`); } const release = await getRelease(slug); if (!release) notFound(); const displayName = release.name ?? ""; const displayArtist = release.albumArtist ?? ""; const displayDate = formatReleaseDate(release.releaseDate); const artists = release.artists ? dedupeById(release.artists).sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")) : []; const composers = dedupeById([...(release.composers ?? []), ...(release.arrangers ?? [])]).sort( (a, b) => (a.sortKey ?? "").localeCompare(b.sortKey ?? ""), ); const hasCreditsInfo = (release.credits && release.credits.length > 0) || release.recordingDate || release.recordingLocation || release.recordingFormat || release.masteringFormat || release.releaseDate || release.bookletPdfUrl; const hasEquipment = release.equipment && release.equipment.length > 0; // Medusa product lookup — match by UPC/EAN, fallback to catalogue number let formatGroups: Awaited> = []; if (release.catalogNo) { const product = release.upc ? await getProductByEan(release.upc) : await getProductByCatalogNo(release.catalogNo); if (product) { formatGroups = matchVariants(release.catalogNo, product.variants); } } const albumUrl = `https://trptk.com/release/${slug}`; const formatMap: Record = { album: "AlbumRelease", ep: "EPRelease", single: "SingleRelease", }; const genreLabelMap: Record = { earlyMusic: "Early Music", baroque: "Baroque", classical: "Classical", romantic: "Romantic", contemporary: "Contemporary", worldMusic: "World Music", jazz: "Jazz", crossover: "Crossover", electronic: "Electronic", minimal: "Minimal", popRock: "Pop / Rock", }; const sameAs = [release.spotifyUrl, release.appleMusicUrl, release.tidalUrl].filter( Boolean, ) as string[]; const jsonLd = { "@context": "https://schema.org", "@type": "MusicAlbum", "@id": albumUrl, name: release.name, url: albumUrl, ...(release.shortDescription && { description: release.shortDescription }), ...(release.albumArtist && { byArtist: { "@type": "MusicGroup", name: release.albumArtist }, }), ...(release.albumCover && { image: urlFor(release.albumCover).width(800).url(), }), ...(release.releaseDate && { datePublished: release.releaseDate }), ...(release.catalogNo && { catalogNumber: release.catalogNo }), ...(release.label === "TRPTK" && { recordLabel: { "@type": "Organization", name: "TRPTK" }, }), ...(release.format && formatMap[release.format] && { albumReleaseType: `https://schema.org/${formatMap[release.format]}`, }), ...(release.genre?.length && { genre: release.genre.map((g) => genreLabelMap[g] || g), }), ...(sameAs.length > 0 && { sameAs }), ...(release.tracks?.length && { numTracks: release.tracks.length, track: release.tracks.map((t, i) => { const seconds = parseDuration(t.duration); const iso = toISO8601Duration(seconds); return { "@type": "MusicRecording", name: t.displayTitle || t.workTitle, position: i + 1, ...(iso && { duration: iso }), inAlbum: { "@id": albumUrl }, }; }), }), }; return ( <>