Initial commit

This commit is contained in:
Brendon Heinst 2026-02-24 17:14:07 +01:00
commit 2409fecfef
154 changed files with 34572 additions and 0 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
.next
.env*
.git

42
.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
/.vscode/
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./app/globals.css"
}

2
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,2 @@
{
}

32
Dockerfile Normal file
View file

@ -0,0 +1,32 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Only NEXT_PUBLIC_ vars are needed at build time (inlined into client bundle)
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_SANITY_PROJECT_ID
ARG NEXT_PUBLIC_SANITY_DATASET
ARG NEXT_PUBLIC_SANITY_API_VERSION
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_SANITY_PROJECT_ID=$NEXT_PUBLIC_SANITY_PROJECT_ID
ENV NEXT_PUBLIC_SANITY_DATASET=$NEXT_PUBLIC_SANITY_DATASET
ENV NEXT_PUBLIC_SANITY_API_VERSION=$NEXT_PUBLIC_SANITY_API_VERSION
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
USER nextjs
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "server.js"]

12
app/[slug]/layout.tsx Normal file
View file

@ -0,0 +1,12 @@
import { ThemeToggleButton } from "@/components/header/ThemeToggleButton";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="fixed top-8 right-8 z-50 hidden sm:block">
<ThemeToggleButton />
</div>
{children}
</>
);
}

135
app/[slug]/page.tsx Normal file
View file

@ -0,0 +1,135 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { cache } from "react";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { AnimatedText } from "@/components/AnimatedText";
import { ReleaseCover } from "@/components/release/ReleaseCover";
import { StreamingLinks } from "@/components/release/StreamingLinks";
import { urlFor } from "@/lib/sanityImage";
import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants";
import { buildLinks, type Release } from "@/lib/release";
export const dynamicParams = true;
export const revalidate = 86400;
const RELEASE_BY_CATALOG_NO_QUERY = defineQuery(`
*[_type == "release" && lower(catalogNo) == $slug][0]{
name,
albumArtist,
label,
catalogNo,
albumCover,
officialUrl,
spotifyUrl,
appleMusicUrl,
deezerUrl,
amazonMusicUrl,
tidalUrl,
qobuzUrl,
}
`);
const getRelease = cache(async (slug: string) => {
try {
const release = await sanity.fetch<Release>(RELEASE_BY_CATALOG_NO_QUERY, { slug });
return release;
} catch (error) {
console.error("Failed to fetch release:", error);
return null;
}
});
const RELEASE_CATALOG_SLUGS_QUERY = defineQuery(
`*[_type == "release" && defined(catalogNo)][]{ "slug": lower(catalogNo) }`,
);
export async function generateStaticParams() {
const releases = await sanity.fetch<{ slug: string }[]>(RELEASE_CATALOG_SLUGS_QUERY);
return releases.map(({ slug }) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
if (!slug) return { title: "TRPTK" };
const release = await getRelease(slug.toLowerCase());
if (!release) return { title: "TRPTK" };
const description = `Listen to ${release.name}${release.albumArtist ? ` by ${release.albumArtist}` : ""} on all major streaming platforms.`;
const ogImage = release.albumCover
? urlFor(release.albumCover).width(1200).height(1200).url()
: undefined;
return {
title: `${release.albumArtist ? `${release.albumArtist}` : ""}${release.name}`,
description,
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] }),
},
};
}
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(`/${normalizedSlug}`);
}
const release = await getRelease(slug);
if (!release) notFound();
const links = buildLinks(release);
const title = release.name ?? slug;
const albumArtist = release.albumArtist ?? "";
return (
<div className="flex h-dvh w-full flex-col">
<main className="mx-auto my-auto w-full max-w-90 px-6 py-12 text-center font-silka text-sm text-lighttext transition-all duration-1000 ease-in-out md:px-8 md:py-16 dark:text-darktext">
<header>
<ReleaseCover
src={release.albumCover ? urlFor(release.albumCover).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Album cover image for ${title} by ${albumArtist}`}
/>
<div className="my-10">
<AnimatedText
text={title}
as="h1"
className="mb-2 font-argesta text-2xl break-words text-lighttext dark:text-white"
/>
<AnimatedText
text={albumArtist}
as="h2"
className="text-sm break-words text-lightsec dark:text-darksec"
delay={0.25}
/>
</div>
</header>
<StreamingLinks releaseName={release.name} links={links} />
</main>
</div>
);
}

18
app/account/layout.tsx Normal file
View file

@ -0,0 +1,18 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Account",
robots: { index: false, follow: false },
};
export default function AccountLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { AuthForm } from "@/components/auth/AuthForm";
function isSafeRedirect(url: string | null): string {
if (!url || !url.startsWith("/") || url.startsWith("//") || url.includes("://")) {
return "/account";
}
return url;
}
function LoginContent() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = isSafeRedirect(searchParams.get("redirect"));
return (
<main className="mx-auto max-w-md px-6 py-16">
<AuthForm onSuccess={() => router.push(redirect)} />
</main>
);
}
export default function LoginPage() {
return (
<Suspense>
<LoginContent />
</Suspense>
);
}

48
app/account/page.tsx Normal file
View file

@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/auth/AuthContext";
import { AccountTabs } from "@/components/account/AccountTabs";
import { IconButton } from "@/components/IconButton";
import { IoLogInOutline } from "react-icons/io5";
export default function AccountPage() {
const { customer, isAuthenticated, isLoading, logout } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace("/account/login");
}
}, [isLoading, isAuthenticated, router]);
if (isLoading || !customer) {
return (
<main className="flex min-h-[60vh] items-center justify-center">
<p className="text-lightsec dark:text-darksec">Loading</p>
</main>
);
}
return (
<main className="mx-auto max-w-250 px-6 py-12 md:px-8 md:py-16">
<div className="flex items-center justify-between">
<div>
<h1 className="font-argesta text-3xl">My Account</h1>
<p className="mt-2 text-sm text-lightsec dark:text-darksec">{customer.email}</p>
</div>
<IconButton
onClick={async () => {
await logout();
router.push("/");
}}
>
<IoLogInOutline />
</IconButton>
</div>
<AccountTabs />
</main>
);
}

BIN
app/android-chrome-192x192.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

BIN
app/android-chrome-512x512.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { getAuthToken, medusaAuthFetch } from "@/lib/auth";
import { parseBody, checkCsrf, pickAddressFields, isValidMedusaId } from "@/lib/apiUtils";
type Params = { params: Promise<{ id: string }> };
export async function POST(request: Request, { params }: Params) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const token = await getAuthToken();
if (!token) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const { id } = await params;
if (!isValidMedusaId(id)) {
return NextResponse.json({ error: "Invalid address ID" }, { status: 400 });
}
const body = await parseBody(request);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const address = pickAddressFields(body);
if (!address) {
return NextResponse.json({ error: "Invalid address data" }, { status: 400 });
}
try {
const data = await medusaAuthFetch<{ address: unknown }>(
`/store/customers/me/addresses/${id}`,
{
method: "POST",
body: JSON.stringify(address),
},
);
return NextResponse.json(data);
} catch (e) {
console.error("[account:addresses:update]", (e as Error).message);
return NextResponse.json(
{ error: "Failed to update address" },
{ status: 500 },
);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const token = await getAuthToken();
if (!token) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const { id } = await params;
if (!isValidMedusaId(id)) {
return NextResponse.json({ error: "Invalid address ID" }, { status: 400 });
}
try {
await medusaAuthFetch(`/store/customers/me/addresses/${id}`, {
method: "DELETE",
});
return NextResponse.json({ success: true });
} catch (e) {
console.error("[account:addresses:delete]", (e as Error).message);
return NextResponse.json(
{ error: "Failed to delete address" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { getAuthToken, medusaAuthFetch } from "@/lib/auth";
import { parseBody, checkCsrf, pickAddressFields } from "@/lib/apiUtils";
export async function GET() {
const token = await getAuthToken();
if (!token) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
try {
const data = await medusaAuthFetch<{ addresses: unknown[] }>(
"/store/customers/me/addresses",
);
return NextResponse.json(data);
} catch (e) {
console.error("[account:addresses:list]", (e as Error).message);
return NextResponse.json(
{ error: "Failed to fetch addresses" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const token = await getAuthToken();
if (!token) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const body = await parseBody(request);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const address = pickAddressFields(body);
if (!address) {
return NextResponse.json({ error: "Invalid address data" }, { status: 400 });
}
try {
const data = await medusaAuthFetch<{ address: unknown }>(
"/store/customers/me/addresses",
{
method: "POST",
body: JSON.stringify(address),
},
);
return NextResponse.json(data);
} catch (e) {
console.error("[account:addresses:create]", (e as Error).message);
return NextResponse.json(
{ error: "Failed to save address" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,221 @@
import { NextResponse } from "next/server";
import { defineQuery } from "next-sanity";
import { getAuthToken, medusaAuthFetch } from "@/lib/auth";
import { getAllProducts } from "@/lib/medusa";
import { sanity } from "@/lib/sanity";
import { formatDisplayName } from "@/lib/variants";
const RELEASES_BY_CATALOG_NOS_QUERY = defineQuery(`
*[_type == "release" && catalogNo in $catalogNos]{
catalogNo,
name,
albumArtist,
"slug": slug.current,
albumCover,
releaseDate,
genre,
instrumentation
}
`);
const MEDUSA_URL = process.env.MEDUSA_URL ?? process.env.NEXT_PUBLIC_MEDUSA_URL ?? "http://localhost:9000";
const API_KEY = process.env.MEDUSA_PUBLISHABLE_KEY ?? process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY ?? "";
type Order = { id: string };
type RawDownload = {
product_title?: string;
variant_title?: string;
sku?: string;
download_url?: string;
};
type RawUpcoming = {
product_title?: string;
variant_title?: string;
sku?: string;
release_date?: string;
};
type SanityRelease = {
catalogNo: string;
name: string;
albumArtist: string;
slug: string;
albumCover: unknown;
releaseDate: string | null;
genre: string[] | null;
instrumentation: string[] | null;
};
export async function GET() {
const token = await getAuthToken();
if (!token) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
try {
// Fetch all customer orders
const ordersData = await medusaAuthFetch<{ orders: Order[] }>(
"/store/orders",
);
const allDownloads: RawDownload[] = [];
const allUpcoming: RawUpcoming[] = [];
// For each order, fetch digital downloads via the custom Medusa endpoint
for (const order of ordersData.orders ?? []) {
try {
const res = await fetch(
`${MEDUSA_URL}/store/order-downloads?order_id=${encodeURIComponent(order.id)}`,
{
headers: {
"x-publishable-api-key": API_KEY,
Authorization: `Bearer ${token}`,
},
},
);
if (res.ok) {
const data = await res.json();
if (data.downloads?.length) allDownloads.push(...data.downloads);
if (data.upcoming?.length) allUpcoming.push(...data.upcoming);
}
} catch {
// Non-critical — skip individual order failures
}
}
// Fetch grant-based downloads (admin-granted access without an order)
try {
const grantsData = await medusaAuthFetch<{ downloads: RawDownload[] }>(
"/store/customers/me/download-grants",
);
if (grantsData.downloads?.length) allDownloads.push(...grantsData.downloads);
} catch {
// Non-critical — grants may not exist or endpoint may not be available
}
// Deduplicate by SKU
const seenDownloads = new Set<string>();
const uniqueDownloads = allDownloads.filter((d) => {
if (!d.sku || seenDownloads.has(d.sku)) return false;
seenDownloads.add(d.sku);
return true;
});
const seenUpcoming = new Set<string>();
const uniqueUpcoming = allUpcoming.filter((u) => {
if (!u.sku || seenUpcoming.has(u.sku)) return false;
seenUpcoming.add(u.sku);
return true;
});
// Build product_title → catalogue_number map via Medusa products
const titleToCatalogNo = new Map<string, string>();
try {
const products = await getAllProducts();
for (const p of products) {
if (p.metadata?.catalogue_number && typeof p.metadata.catalogue_number === "string") {
titleToCatalogNo.set(p.title, p.metadata.catalogue_number);
}
}
} catch {
// Non-critical — cards will just miss Sanity data
}
// Query Sanity for matching releases
const catalogNos = [...new Set(titleToCatalogNo.values())];
const catalogNoToRelease = new Map<string, SanityRelease>();
if (catalogNos.length > 0) {
try {
const releases = await sanity.fetch<SanityRelease[]>(
RELEASES_BY_CATALOG_NOS_QUERY,
{ catalogNos },
);
for (const r of releases) {
catalogNoToRelease.set(r.catalogNo, r);
}
} catch {
// Non-critical — cards will render without covers
}
}
// Helper to look up Sanity data for a product title
function getSanityData(productTitle?: string) {
if (!productTitle) return null;
const catalogNo = titleToCatalogNo.get(productTitle);
if (!catalogNo) return null;
return catalogNoToRelease.get(catalogNo) ?? null;
}
// Helper to build enriched base fields from Sanity data
function enrichedBase(productTitle: string) {
const release = getSanityData(productTitle);
return {
product_title: productTitle,
albumCover: release?.albumCover ?? null,
albumArtist: release?.albumArtist ?? null,
slug: release?.slug ?? null,
catalogNo: release?.catalogNo ?? null,
releaseDate: release?.releaseDate ?? null,
genre: release?.genre ?? [],
instrumentation: release?.instrumentation ?? [],
};
}
// Group downloads by product_title, enriched with Sanity data
const downloadGroups = new Map<string, ReturnType<typeof enrichedBase> & {
formats: { variant_title: string; sku: string; download_url: string }[];
}>();
for (const dl of uniqueDownloads) {
const title = dl.product_title ?? "Unknown";
const existing = downloadGroups.get(title);
const fmt = {
variant_title: formatDisplayName(dl.sku ?? "") ?? dl.variant_title ?? "",
sku: dl.sku ?? "",
download_url: dl.download_url ?? "",
};
if (existing) {
existing.formats.push(fmt);
} else {
downloadGroups.set(title, { ...enrichedBase(title), formats: [fmt] });
}
}
// Group upcoming by product_title, enriched with Sanity data
const upcomingGroups = new Map<string, ReturnType<typeof enrichedBase> & {
release_date: string;
formats: { variant_title: string; sku: string }[];
}>();
for (const up of uniqueUpcoming) {
const title = up.product_title ?? "Unknown";
const existing = upcomingGroups.get(title);
const fmt = {
variant_title: formatDisplayName(up.sku ?? "") ?? up.variant_title ?? "",
sku: up.sku ?? "",
};
if (existing) {
existing.formats.push(fmt);
} else {
upcomingGroups.set(title, {
...enrichedBase(title),
release_date: up.release_date ?? "",
formats: [fmt],
});
}
}
return NextResponse.json({
downloads: Array.from(downloadGroups.values()),
upcoming: Array.from(upcomingGroups.values()),
});
} catch (e) {
console.error("[account:downloads]", (e as Error).message);
return NextResponse.json(
{ error: "Failed to fetch downloads" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,99 @@
import { NextResponse } from "next/server";
import { cookies, headers } from "next/headers";
import { createRateLimiter, getClientIp } from "@/lib/rateLimit";
import { checkCsrf } from "@/lib/apiUtils";
const MEDUSA_URL = process.env.MEDUSA_URL ?? process.env.NEXT_PUBLIC_MEDUSA_URL ?? "http://localhost:9000";
const API_KEY = process.env.MEDUSA_PUBLISHABLE_KEY ?? process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY ?? "";
// 5 login attempts per IP per 15 minutes
const limiter = createRateLimiter({ windowMs: 15 * 60 * 1000, maxRequests: 5 });
export async function POST(request: Request) {
// CSRF check
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
// Rate limit by IP
const hdrs = await headers();
const ip = getClientIp(hdrs);
const limit = limiter.check(`login:${ip}`);
if (!limit.allowed) {
return NextResponse.json(
{ error: "Too many login attempts. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) } },
);
}
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { email, password } = body;
// Server-side validation
if (typeof email !== "string" || typeof password !== "string") {
return NextResponse.json({ error: "Email and password are required" }, { status: 400 });
}
const trimmedEmail = email.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
return NextResponse.json({ error: "Invalid email address" }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 });
}
if (password.length > 128) {
return NextResponse.json({ error: "Password too long" }, { status: 400 });
}
// Authenticate with Medusa
const authRes = await fetch(`${MEDUSA_URL}/auth/customer/emailpass`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-publishable-api-key": API_KEY,
},
body: JSON.stringify({ email: trimmedEmail, password }),
});
if (!authRes.ok) {
return NextResponse.json(
{ error: "Invalid email or password" },
{ status: 401 },
);
}
const { token } = await authRes.json();
// Set httpOnly cookie
const cookieStore = await cookies();
cookieStore.set("medusa_auth_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 604800, // 7 days
});
// Fetch customer profile
const meRes = await fetch(`${MEDUSA_URL}/store/customers/me`, {
headers: {
"x-publishable-api-key": API_KEY,
Authorization: `Bearer ${token}`,
},
});
if (!meRes.ok) {
return NextResponse.json(
{ error: "Failed to fetch profile" },
{ status: 500 },
);
}
const { customer } = await meRes.json();
return NextResponse.json({ customer });
}

View file

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { checkCsrf } from "@/lib/apiUtils";
export async function POST() {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const cookieStore = await cookies();
cookieStore.delete("medusa_auth_token");
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { getAuthToken, medusaAuthFetch } from "@/lib/auth";
import { cookies } from "next/headers";
export async function GET() {
const token = await getAuthToken();
if (!token) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
try {
const data = await medusaAuthFetch<{ customer: unknown }>("/store/customers/me");
return NextResponse.json(data);
} catch {
// Token expired or invalid — clear the cookie
const cookieStore = await cookies();
cookieStore.delete("medusa_auth_token");
return NextResponse.json({ error: "Session expired" }, { status: 401 });
}
}

View file

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { getAuthToken, medusaAuthFetch } from "@/lib/auth";
export async function GET() {
const token = await getAuthToken();
if (!token) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
try {
const data = await medusaAuthFetch<{ orders: unknown[] }>(
"/store/orders?order=-created_at&fields=*items,*items.variant",
);
return NextResponse.json(data);
} catch (e) {
console.error("[account:orders]", (e as Error).message);
return NextResponse.json(
{ error: "Failed to fetch orders" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,176 @@
import { NextResponse } from "next/server";
import { cookies, headers } from "next/headers";
import { createRateLimiter, getClientIp } from "@/lib/rateLimit";
import { checkCsrf } from "@/lib/apiUtils";
const MEDUSA_URL = process.env.MEDUSA_URL ?? process.env.NEXT_PUBLIC_MEDUSA_URL ?? "http://localhost:9000";
const API_KEY = process.env.MEDUSA_PUBLISHABLE_KEY ?? process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY ?? "";
// 3 registration attempts per IP per 15 minutes
const limiter = createRateLimiter({ windowMs: 15 * 60 * 1000, maxRequests: 3 });
export async function POST(request: Request) {
// CSRF check
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
// Rate limit by IP
const hdrs = await headers();
const ip = getClientIp(hdrs);
const limit = limiter.check(`register:${ip}`);
if (!limit.allowed) {
return NextResponse.json(
{ error: "Too many registration attempts. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) } },
);
}
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { email, password, first_name, last_name } = body;
// Server-side validation
if (
typeof email !== "string" ||
typeof password !== "string" ||
typeof first_name !== "string" ||
typeof last_name !== "string"
) {
return NextResponse.json(
{ error: "All fields are required" },
{ status: 400 },
);
}
const trimmedEmail = email.trim().toLowerCase();
const trimmedFirst = first_name.trim();
const trimmedLast = last_name.trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
return NextResponse.json({ error: "Invalid email address" }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 });
}
if (password.length > 128) {
return NextResponse.json({ error: "Password too long" }, { status: 400 });
}
if (!/[A-Z]/.test(password)) {
return NextResponse.json({ error: "Password must contain at least one uppercase letter" }, { status: 400 });
}
if (!/[a-z]/.test(password)) {
return NextResponse.json({ error: "Password must contain at least one lowercase letter" }, { status: 400 });
}
if (!/\d/.test(password)) {
return NextResponse.json({ error: "Password must contain at least one number" }, { status: 400 });
}
if (!/[^A-Za-z0-9]/.test(password)) {
return NextResponse.json({ error: "Password must contain at least one special character" }, { status: 400 });
}
if (!trimmedFirst || !trimmedLast) {
return NextResponse.json({ error: "First and last name are required" }, { status: 400 });
}
// Step 1: Register auth identity with Medusa
const authRes = await fetch(`${MEDUSA_URL}/auth/customer/emailpass/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-publishable-api-key": API_KEY,
},
body: JSON.stringify({ email: trimmedEmail, password }),
});
if (!authRes.ok) {
// Use a generic message to prevent email enumeration
return NextResponse.json(
{ error: "Registration failed. Please try a different email or sign in." },
{ status: authRes.status === 409 ? 409 : authRes.status },
);
}
const { token } = await authRes.json();
// Set httpOnly cookie
const cookieStore = await cookies();
cookieStore.set("medusa_auth_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 604800,
});
// Step 2: Create the customer profile
const customerRes = await fetch(`${MEDUSA_URL}/store/customers`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-publishable-api-key": API_KEY,
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
email: trimmedEmail,
first_name: trimmedFirst,
last_name: trimmedLast,
}),
});
if (!customerRes.ok) {
return NextResponse.json(
{ error: "Failed to create customer profile" },
{ status: 500 },
);
}
// Step 3: Log in to get a fully-scoped token.
// The registration token was issued before the customer entity existed,
// so it may lack the customer association needed for endpoints like
// /store/customers/me/addresses. A fresh login token resolves this.
const loginRes = await fetch(`${MEDUSA_URL}/auth/customer/emailpass`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-publishable-api-key": API_KEY,
},
body: JSON.stringify({ email: trimmedEmail, password }),
});
if (loginRes.ok) {
const { token: loginToken } = await loginRes.json();
cookieStore.set("medusa_auth_token", loginToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 604800,
});
}
// Fetch the customer profile with the best available token
const finalToken = loginRes.ok
? (await cookieStore.get("medusa_auth_token"))?.value ?? token
: token;
const meRes = await fetch(`${MEDUSA_URL}/store/customers/me`, {
headers: {
"x-publishable-api-key": API_KEY,
Authorization: `Bearer ${finalToken}`,
},
});
if (!meRes.ok) {
return NextResponse.json(
{ error: "Failed to fetch customer profile" },
{ status: 500 },
);
}
const { customer } = await meRes.json();
return NextResponse.json({ customer });
}

View file

@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { updateCartItem, removeFromCart } from "@/lib/medusa";
import { parseBody, isPositiveInt, isValidMedusaId, badRequest, checkCsrf } from "@/lib/apiUtils";
// POST /api/cart/[cartId]/items/[itemId] — update quantity
export async function POST(
request: Request,
{ params }: { params: Promise<{ cartId: string; itemId: string }> },
) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const { cartId, itemId } = await params;
if (!isValidMedusaId(cartId) || !isValidMedusaId(itemId)) {
return badRequest("Invalid ID format");
}
const body = await parseBody<{ quantity?: unknown }>(request);
if (!body) return badRequest("Invalid request body");
const { quantity } = body;
if (!isPositiveInt(quantity)) {
return badRequest("Quantity must be a positive integer");
}
try {
const cart = await updateCartItem(cartId, itemId, quantity);
return NextResponse.json(cart);
} catch (e) {
console.error("[cart:update]", (e as Error).message);
return NextResponse.json({ error: "Failed to update item" }, { status: 500 });
}
}
// DELETE /api/cart/[cartId]/items/[itemId] — remove line item
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ cartId: string; itemId: string }> },
) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const { cartId, itemId } = await params;
if (!isValidMedusaId(cartId) || !isValidMedusaId(itemId)) {
return badRequest("Invalid ID format");
}
try {
const cart = await removeFromCart(cartId, itemId);
return NextResponse.json(cart);
} catch (e) {
console.error("[cart:remove]", (e as Error).message);
return NextResponse.json({ error: "Failed to remove item" }, { status: 500 });
}
}

View file

@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { addToCart } from "@/lib/medusa";
import { parseBody, isNonEmptyString, isPositiveInt, isValidMedusaId, badRequest, checkCsrf } from "@/lib/apiUtils";
// POST /api/cart/[cartId]/items — add a line item
export async function POST(
request: Request,
{ params }: { params: Promise<{ cartId: string }> },
) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const { cartId } = await params;
if (!isValidMedusaId(cartId)) {
return badRequest("Invalid cart ID format");
}
const body = await parseBody<{ variant_id?: unknown; quantity?: unknown }>(request);
if (!body) return badRequest("Invalid request body");
const { variant_id, quantity } = body;
if (!isNonEmptyString(variant_id)) {
return badRequest("Missing or invalid variant_id");
}
if (!isValidMedusaId(variant_id)) {
return badRequest("Invalid variant ID format");
}
const qty = quantity ?? 1;
if (!isPositiveInt(qty)) {
return badRequest("Quantity must be a positive integer");
}
try {
const cart = await addToCart(cartId, variant_id, qty);
return NextResponse.json(cart);
} catch (e) {
console.error("[cart:add]", (e as Error).message);
return NextResponse.json({ error: "Failed to add item to cart" }, { status: 500 });
}
}

View file

@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { applyPromoCode, removePromoCode } from "@/lib/medusa";
import { parseBody, isNonEmptyString, isValidMedusaId, badRequest, checkCsrf } from "@/lib/apiUtils";
// POST /api/cart/[cartId]/promotions — apply a promo code
export async function POST(
request: Request,
{ params }: { params: Promise<{ cartId: string }> },
) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const { cartId } = await params;
if (!isValidMedusaId(cartId)) {
return badRequest("Invalid cart ID format");
}
const body = await parseBody<{ code?: unknown }>(request);
if (!body) return badRequest("Invalid request body");
const { code } = body;
if (!isNonEmptyString(code)) {
return badRequest("Missing or invalid promo code");
}
try {
const cart = await applyPromoCode(cartId, code.trim());
return NextResponse.json(cart);
} catch (e) {
const msg = (e as Error).message ?? "";
console.error("[cart:promo:apply]", msg);
return NextResponse.json(
{ error: "Invalid or expired promo code" },
{ status: 400 },
);
}
}
// DELETE /api/cart/[cartId]/promotions — remove a promo code
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ cartId: string }> },
) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const { cartId } = await params;
if (!isValidMedusaId(cartId)) {
return badRequest("Invalid cart ID format");
}
const body = await parseBody<{ code?: unknown }>(request);
if (!body) return badRequest("Invalid request body");
const { code } = body;
if (!isNonEmptyString(code)) {
return badRequest("Missing or invalid promo code");
}
try {
const cart = await removePromoCode(cartId, code.trim());
return NextResponse.json(cart);
} catch (e) {
console.error("[cart:promo:remove]", (e as Error).message);
return NextResponse.json(
{ error: "Failed to remove promo code" },
{ status: 500 },
);
}
}

58
app/api/cart/route.ts Normal file
View file

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { createCart, getCart } from "@/lib/medusa";
import { headers } from "next/headers";
import { createRateLimiter, getClientIp } from "@/lib/rateLimit";
import { isNonEmptyString, checkCsrf, isValidMedusaId, badRequest } from "@/lib/apiUtils";
import { getAuthToken } from "@/lib/auth";
// 10 cart creations per IP per 15 minutes
const limiter = createRateLimiter({ windowMs: 15 * 60 * 1000, maxRequests: 10 });
// POST /api/cart — create a new cart
export async function POST() {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const hdrs = await headers();
const ip = getClientIp(hdrs);
const limit = limiter.check(`cart:${ip}`);
if (!limit.allowed) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) } },
);
}
// Pass auth token so Medusa associates the cart with the logged-in customer
const authToken = (await getAuthToken()) ?? undefined;
try {
const cart = await createCart(authToken);
return NextResponse.json(cart);
} catch (e) {
console.error("[cart:create]", (e as Error).message);
return NextResponse.json({ error: "Failed to create cart" }, { status: 500 });
}
}
// GET /api/cart?id=cart_xxx — fetch an existing cart
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!isNonEmptyString(id)) {
return badRequest("Missing cart id");
}
if (!isValidMedusaId(id)) {
return badRequest("Invalid cart ID format");
}
try {
const cart = await getCart(id);
if (!cart) {
return NextResponse.json({ error: "Cart not found" }, { status: 404 });
}
return NextResponse.json(cart);
} catch (e) {
console.error("[cart:get]", (e as Error).message);
return NextResponse.json({ error: "Failed to fetch cart" }, { status: 500 });
}
}

View file

@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { completeCart } from "@/lib/medusa";
import { getAuthToken } from "@/lib/auth";
import { parseBody, isNonEmptyString, isValidMedusaId, badRequest, checkCsrf } from "@/lib/apiUtils";
// POST /api/checkout/complete — finalize cart into an order
export async function POST(request: Request) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const body = await parseBody<{ cartId?: unknown }>(request);
if (!body) return badRequest("Invalid request body");
const { cartId } = body;
if (!isNonEmptyString(cartId)) {
return badRequest("Missing cartId");
}
if (!isValidMedusaId(cartId)) {
return badRequest("Invalid cart ID format");
}
// Pass auth token so the resulting order is linked to the customer
const authToken = (await getAuthToken()) ?? undefined;
try {
const result = await completeCart(cartId, authToken);
return NextResponse.json(result);
} catch (e) {
console.error("[checkout:complete]", (e as Error).message);
return NextResponse.json({ error: "Failed to complete order" }, { status: 500 });
}
}

View file

@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { isNonEmptyString } from "@/lib/apiUtils";
import { getAuthToken } from "@/lib/auth";
const MEDUSA_URL = process.env.MEDUSA_URL ?? process.env.NEXT_PUBLIC_MEDUSA_URL ?? "http://localhost:9000";
const API_KEY = process.env.MEDUSA_PUBLISHABLE_KEY ?? process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY ?? "";
// GET /api/checkout/downloads?orderId=xxx
export async function GET(request: Request) {
const token = await getAuthToken();
if (!token) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const orderId = searchParams.get("orderId");
if (!isNonEmptyString(orderId)) {
return NextResponse.json({ error: "Missing orderId" }, { status: 400 });
}
try {
const res = await fetch(
`${MEDUSA_URL}/store/order-downloads?order_id=${encodeURIComponent(orderId)}`,
{
headers: {
"x-publishable-api-key": API_KEY,
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
console.error("[downloads]", `Medusa returned ${res.status} for order ${orderId}`);
return NextResponse.json(
{ error: "Failed to fetch downloads" },
{ status: res.status >= 400 && res.status < 500 ? res.status : 500 },
);
}
const data = await res.json();
return NextResponse.json(data);
} catch (e) {
console.error("[downloads]", (e as Error).message);
return NextResponse.json({ error: "Failed to fetch downloads" }, { status: 500 });
}
}

View file

@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { createPaymentCollection, initPaymentSession } from "@/lib/medusa";
import { parseBody, isNonEmptyString, isValidMedusaId, badRequest, checkCsrf } from "@/lib/apiUtils";
// POST /api/checkout/payment — create payment collection + Mollie session
export async function POST(request: Request) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const body = await parseBody<{ cartId?: unknown; providerId?: unknown }>(request);
if (!body) return badRequest("Invalid request body");
const { cartId, providerId } = body;
if (!isNonEmptyString(cartId)) {
return badRequest("Missing cartId");
}
if (!isValidMedusaId(cartId)) {
return badRequest("Invalid cart ID format");
}
// Step 1: Create a payment collection for the cart
let collection;
try {
collection = await createPaymentCollection(cartId);
} catch (e) {
console.error("[payment:collection]", (e as Error).message);
return NextResponse.json(
{ error: "Failed to create payment collection" },
{ status: 500 },
);
}
// Step 2: Initialize a payment session with the provider (Mollie)
try {
const provider =
isNonEmptyString(providerId) ? providerId : "pp_mollie-hosted-checkout_mollie";
const updated = await initPaymentSession(collection.id, provider);
return NextResponse.json(updated);
} catch (e) {
console.error("[payment:session]", (e as Error).message);
return NextResponse.json(
{ error: "Failed to initialize payment session" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { addShippingMethod } from "@/lib/medusa";
import { parseBody, isNonEmptyString, isValidMedusaId, badRequest, checkCsrf } from "@/lib/apiUtils";
// POST /api/checkout/shipping-method — add a shipping method to the cart
export async function POST(request: Request) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const body = await parseBody<{ cartId?: unknown; optionId?: unknown }>(request);
if (!body) return badRequest("Invalid request body");
const { cartId, optionId } = body;
if (!isNonEmptyString(cartId) || !isNonEmptyString(optionId)) {
return badRequest("Missing cartId or optionId");
}
if (!isValidMedusaId(cartId) || !isValidMedusaId(optionId)) {
return badRequest("Invalid ID format");
}
try {
const cart = await addShippingMethod(cartId, optionId);
return NextResponse.json(cart);
} catch (e) {
console.error("[checkout:shipping-method]", (e as Error).message);
return NextResponse.json({ error: "Failed to set shipping method" }, { status: 500 });
}
}

View file

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { getShippingOptions } from "@/lib/medusa";
import { isNonEmptyString, isValidMedusaId, badRequest } from "@/lib/apiUtils";
// GET /api/checkout/shipping-options?cartId=cart_xxx
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const cartId = searchParams.get("cartId");
if (!isNonEmptyString(cartId)) {
return badRequest("Missing cartId");
}
if (!isValidMedusaId(cartId)) {
return badRequest("Invalid cart ID format");
}
try {
const options = await getShippingOptions(cartId);
return NextResponse.json(options);
} catch (e) {
console.error("[checkout:shipping-options]", (e as Error).message);
return NextResponse.json({ error: "Failed to fetch shipping options" }, { status: 500 });
}
}

View file

@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { updateCart } from "@/lib/medusa";
import { getAuthToken } from "@/lib/auth";
import { parseBody, isNonEmptyString, isValidEmail, isValidMedusaId, badRequest, checkCsrf, pickAddressFields } from "@/lib/apiUtils";
// POST /api/checkout/update — update cart with email + addresses
export async function POST(request: Request) {
const csrfError = await checkCsrf();
if (csrfError) return csrfError;
const body = await parseBody<{
cartId?: unknown;
email?: unknown;
shipping_address?: unknown;
billing_address?: unknown;
}>(request);
if (!body) return badRequest("Invalid request body");
const { cartId, email, shipping_address, billing_address } = body;
if (!isNonEmptyString(cartId)) {
return badRequest("Missing cartId");
}
if (!isValidMedusaId(cartId)) {
return badRequest("Invalid cart ID format");
}
if (email !== undefined && !isValidEmail(email)) {
return badRequest("Invalid email address");
}
// Sanitize address fields to prevent mass assignment
const sanitizedShipping = shipping_address ? pickAddressFields(shipping_address) : undefined;
const sanitizedBilling = billing_address ? pickAddressFields(billing_address) : undefined;
// Pass auth token so Medusa associates the cart with the logged-in customer
const authToken = (await getAuthToken()) ?? undefined;
try {
const cart = await updateCart(
cartId,
{
email: typeof email === "string" ? email.trim().toLowerCase() : undefined,
shipping_address: sanitizedShipping as Parameters<typeof updateCart>[1]["shipping_address"],
billing_address: sanitizedBilling as Parameters<typeof updateCart>[1]["billing_address"],
},
authToken,
);
return NextResponse.json(cart);
} catch (e) {
console.error("[checkout:update]", (e as Error).message);
return NextResponse.json({ error: "Failed to update cart" }, { status: 500 });
}
}

109
app/api/search/route.ts Normal file
View file

@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from "next/server";
import { sanity } from "@/lib/sanity";
import { urlFor } from "@/lib/sanityImage";
import { foldDiacritics } from "@/lib/diacritics";
import type { SanityImageSource } from "@sanity/image-url";
const SEARCH_QUERY = `{
"releases": *[_type == "release" && defined(slug.current)]{
name, albumArtist, "slug": slug.current, albumCover
},
"artists": *[_type == "artist" && defined(slug.current)]{
name, role, "slug": slug.current, image
},
"composers": *[_type == "composer" && defined(slug.current)]{
name, birthYear, deathYear, "slug": slug.current, image
},
"works": *[_type == "work" && defined(slug.current)]{
title, "composerName": composer->name, "composerSlug": composer->slug.current, "composerImage": composer->image, "arrangerName": arranger->name, "slug": slug.current
},
"blog": *[_type == "blog" && defined(slug.current)]{
title, category, "slug": slug.current, featuredImage
}
}`;
type RawSearchData = {
releases: Array<{ name: string; albumArtist?: string; slug: string; albumCover?: SanityImageSource }>;
artists: Array<{ name: string; role?: string; slug: string; image?: SanityImageSource }>;
composers: Array<{ name: string; birthYear?: number; deathYear?: number; slug: string; image?: SanityImageSource }>;
works: Array<{ title: string; composerName?: string; arrangerName?: string; composerSlug?: string; composerImage?: SanityImageSource; slug: string }>;
blog: Array<{ title: string; category?: string; slug: string; featuredImage?: SanityImageSource }>;
};
let cachedData: RawSearchData | null = null;
let cacheTime = 0;
const CACHE_TTL = 86_400_000; // 24 hours in ms
async function getData(): Promise<RawSearchData> {
const now = Date.now();
if (cachedData && now - cacheTime < CACHE_TTL) return cachedData;
cachedData = await sanity.fetch<RawSearchData>(SEARCH_QUERY);
cacheTime = now;
return cachedData;
}
const MAX_PER_TYPE = 5;
const THUMB_SIZE = 96;
function imageUrl(source?: SanityImageSource): string | undefined {
if (!source) return undefined;
try {
return urlFor(source).width(THUMB_SIZE).height(THUMB_SIZE).url();
} catch {
return undefined;
}
}
function formatYears(birthYear?: number, deathYear?: number): string | undefined {
if (birthYear && deathYear) return `${birthYear}\u2013${deathYear}`;
if (birthYear) return `${birthYear}`;
return undefined;
}
function matches(text: string | undefined, folded: string): boolean {
if (!text) return false;
return foldDiacritics(text.toLowerCase()).includes(folded);
}
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q")?.trim();
if (!q || q.length < 2) {
return NextResponse.json({
releases: [],
artists: [],
composers: [],
works: [],
blog: [],
});
}
const data = await getData();
const folded = foldDiacritics(q.toLowerCase());
const releases = data.releases
.filter((r) => matches(r.name, folded) || matches(r.albumArtist, folded))
.slice(0, MAX_PER_TYPE)
.map((r) => ({ name: r.name, albumArtist: r.albumArtist, slug: r.slug, imageUrl: imageUrl(r.albumCover) }));
const artists = data.artists
.filter((a) => matches(a.name, folded))
.slice(0, MAX_PER_TYPE)
.map((a) => ({ name: a.name, role: a.role, slug: a.slug, imageUrl: imageUrl(a.image) }));
const composers = data.composers
.filter((c) => matches(c.name, folded))
.slice(0, MAX_PER_TYPE)
.map((c) => ({ name: c.name, years: formatYears(c.birthYear, c.deathYear), slug: c.slug, imageUrl: imageUrl(c.image) }));
const works = data.works
.filter((w) => matches(w.title, folded) || matches(w.composerName, folded))
.slice(0, MAX_PER_TYPE)
.map((w) => ({ title: w.title, composerName: w.composerName, arrangerName: w.arrangerName, slug: w.slug, imageUrl: imageUrl(w.composerImage) }));
const blog = data.blog
.filter((b) => matches(b.title, folded))
.slice(0, MAX_PER_TYPE)
.map((b) => ({ title: b.title, category: b.category, slug: b.slug, imageUrl: imageUrl(b.featuredImage) }));
return NextResponse.json({ releases, artists, composers, works, blog });
}

BIN
app/apple-touch-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,12 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

203
app/artist/[slug]/page.tsx Normal file
View file

@ -0,0 +1,203 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { cache } from "react";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { ArtistTabs } from "@/components/artist/ArtistTabs";
import { ArtistReleasesTab, type ArtistRelease } from "@/components/artist/ArtistReleasesTab";
import { ArtistConcertsTab } from "@/components/artist/ArtistConcertsTab";
import { ReleaseCover } from "@/components/release/ReleaseCover";
import { AnimatedText } from "@/components/AnimatedText";
import { urlFor } from "@/lib/sanityImage";
import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants";
import { Breadcrumb } from "@/components/Breadcrumb";
import type { ConcertData } from "@/components/concert/ConcertTable";
export const dynamicParams = true;
export const revalidate = 86400;
type Artist = {
name?: string;
slug?: string;
role?: string;
bio?: any;
image?: any;
releases?: ArtistRelease[];
upcomingConcerts?: ConcertData[];
pastConcerts?: ConcertData[];
};
const CONCERT_PROJECTION = `{
_id,
title,
subtitle,
date,
time,
locationName,
city,
country,
"artists": artists[]->{ _id, name, "slug": slug.current },
ticketUrl
}`;
const ARTIST_DETAIL_QUERY = defineQuery(`
*[_type == "artist" && slug.current == $slug][0]{
name,
role,
"slug": slug.current,
bio,
image,
"releases": *[
_type == "release" &&
references(^._id)
]
| order(releaseDate desc, catalogNo desc) {
_id,
name,
albumArtist,
catalogNo,
"slug": slug.current,
releaseDate,
albumCover,
genre,
instrumentation
},
"upcomingConcerts": *[
_type == "concert" &&
references(^._id) &&
date >= $today
] | order(date asc, time asc) ${CONCERT_PROJECTION},
"pastConcerts": *[
_type == "concert" &&
references(^._id) &&
date < $today
] | order(date desc, time desc) ${CONCERT_PROJECTION}
}
`);
const getArtist = cache(async (slug: string) => {
const today = new Date().toISOString().slice(0, 10);
try {
return await sanity.fetch<Artist>(ARTIST_DETAIL_QUERY, { slug, today });
} catch (error) {
console.error("Failed to fetch artist:", error);
return null;
}
});
const ARTIST_SLUGS_QUERY = defineQuery(
`*[_type == "artist" && defined(slug.current)]{ "slug": slug.current }`,
);
export async function generateStaticParams() {
const slugs = await sanity.fetch<Array<{ slug: string }>>(ARTIST_SLUGS_QUERY);
return slugs.map((a) => ({ slug: a.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug: rawSlug } = await params;
const slug = rawSlug.toLowerCase();
const artist = await getArtist(slug);
if (!artist) notFound();
const description = `Explore ${artist.name}'s releases${artist.role ? `, ${artist.role}` : ""} on TRPTK.`;
const ogImage = artist.image ? urlFor(artist.image).width(1200).height(630).url() : undefined;
return {
title: `${artist.name} ${artist.role ? `${artist.role}` : ""}`,
description,
alternates: { canonical: `/artist/${slug}` },
openGraph: {
title: artist.name,
description: `${artist.role || "Artist"}${artist.releases?.length ? ` - ${artist.releases.length} releases` : ""}`,
type: "profile",
...(ogImage && { images: [{ url: ogImage, width: 1200, height: 630, alt: artist.name }] }),
},
twitter: {
card: "summary_large_image",
title: artist.name,
description,
...(ogImage && { images: [ogImage] }),
},
};
}
export default async function ArtistPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!slug) notFound();
const normalizedSlug = slug.toLowerCase();
if (slug !== normalizedSlug) {
redirect(`/artist/${normalizedSlug}`);
}
const artist = await getArtist(slug);
if (!artist) notFound();
const displayName = artist.name ?? "";
const displayRole = artist.role ?? "";
const jsonLd = {
"@context": "https://schema.org",
"@type": "Person",
name: artist.name,
url: `https://trptk.com/artist/${slug}`,
...(artist.role && { jobTitle: artist.role }),
...(artist.image && { image: urlFor(artist.image).width(800).url() }),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<main className="mx-auto my-auto max-w-250 px-6 py-12 font-silka md:px-8 md:py-16">
<div className="mx-auto grid grid-cols-1 gap-10 sm:grid-cols-2 sm:gap-8 md:gap-12 lg:gap-20">
<div className="flex-1 content-center">
<ReleaseCover
src={artist.image ? urlFor(artist.image).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Photo of ${artist.name}`}
/>
</div>
<div className="flex-1 content-center">
<Breadcrumb crumbs={[{ label: "Artists", href: "/artists" }]} />
<AnimatedText
text={displayName}
as="h1"
className="mb-2 font-argesta text-3xl break-words"
/>
<AnimatedText
text={displayRole}
as="h2"
className="font-silka text-base break-words text-lightsec dark:text-darksec"
delay={0.25}
/>
</div>
</div>
<ArtistTabs
bio={artist.bio}
concerts={
artist.upcomingConcerts?.length || artist.pastConcerts?.length ? (
<ArtistConcertsTab
upcoming={artist.upcomingConcerts ?? []}
past={artist.pastConcerts ?? []}
/>
) : undefined
}
hasReleases={!!artist.releases?.length}
releasesTab={<ArtistReleasesTab releases={artist.releases ?? []} />}
/>
</main>
</>
);
}

19
app/artists/error.tsx Normal file
View file

@ -0,0 +1,19 @@
"use client";
export default function ArtistsError({ reset }: { error: Error; reset: () => void }) {
return (
<main className="mx-auto my-auto max-w-275 px-6 py-12 font-silka md:px-8 md:py-16">
<h1 className="mb-4 font-argesta text-3xl">Something went wrong</h1>
<p className="mb-6 text-lightsec dark:text-darksec">
We couldn&apos;t load the artists. Please try again.
</p>
<button
type="button"
onClick={reset}
className="rounded-xl border border-lightline px-6 py-3 text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue dark:border-darkline dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white"
>
Try again
</button>
</main>
);
}

12
app/artists/layout.tsx Normal file
View file

@ -0,0 +1,12 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

153
app/artists/page.tsx Normal file
View file

@ -0,0 +1,153 @@
import type { Metadata } from "next";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { ArtistCard } from "@/components/artist/ArtistCard";
import type { ArtistCardData } from "@/components/artist/types";
import { AnimatedText } from "@/components/AnimatedText";
import { SearchBar } from "@/components/SearchBar";
import { PaginationNav } from "@/components/PaginationNav";
import { CARD_GRID_CLASSES_4 } from "@/lib/constants";
import { PAGE_SIZE, clampInt, normalizeQuery, groqLikeParam } from "@/lib/listHelpers";
import type { SortOption } from "@/components/SortDropdown";
export const revalidate = 86400;
type SortMode = "name" | "sortKey";
const SORT_OPTIONS: SortOption<SortMode>[] = [
{ value: "name", label: "Sort by first name" },
{ value: "sortKey", label: "Sort by last name" },
];
function normalizeSort(s: string | undefined): SortMode {
return s === "sortKey" ? "sortKey" : "name";
}
const ARTISTS_BY_NAME_QUERY = defineQuery(`
*[
_type == "artist" &&
(
$q == "" ||
name match $qPattern ||
role match $qPattern
)
]
| order(lower(name) asc)
[$start...$end]{
_id,
name,
role,
"slug": slug.current,
image
}
`);
const ARTISTS_BY_SORT_KEY_QUERY = defineQuery(`
*[
_type == "artist" &&
(
$q == "" ||
name match $qPattern ||
role match $qPattern
)
]
| order(lower(coalesce(sortKey, name)) asc, lower(name) asc)
[$start...$end]{
_id,
name,
role,
"slug": slug.current,
image
}
`);
const ARTISTS_COUNT_QUERY = defineQuery(`
count(*[
_type == "artist" &&
(
$q == "" ||
name match $qPattern ||
role match $qPattern
)
])
`);
export const metadata: Metadata = {
title: "Artists",
description:
"Browse all TRPTK artists. Discover performers, soloists, and ensembles featured on our recordings.",
};
export default async function ArtistsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; q?: string; sort?: string }>;
}) {
const sp = await searchParams;
const q = normalizeQuery(sp.q);
const sort = normalizeSort(sp.sort);
const page = clampInt(sp.page, 1, 1, 9999);
const start = (page - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
const qPattern = q ? `${groqLikeParam(q)}*` : "";
const listQuery = sort === "sortKey" ? ARTISTS_BY_SORT_KEY_QUERY : ARTISTS_BY_NAME_QUERY;
const [artists, total] = await Promise.all([
sanity.fetch<ArtistCardData[]>(listQuery, { start, end, q, qPattern }),
sanity.fetch<number>(ARTISTS_COUNT_QUERY, { q, qPattern }),
]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const buildHref = (nextPage: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (sort !== "name") params.set("sort", sort);
if (nextPage > 1) params.set("page", String(nextPage));
const qs = params.toString();
return qs ? `/artists?${qs}` : "/artists";
};
return (
<main className="mx-auto my-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
<div className="mb-10">
<AnimatedText text="Artists" as="h1" className="font-argesta text-3xl" />
<p className="mt-2 text-base text-lightsec dark:text-darksec">
{total ? `${total} artist(s)` : "No artists found."}
</p>
</div>
<div className="mb-12">
<SearchBar
initialQuery={q}
initialSort={sort}
initialPage={safePage}
defaultSort="name"
placeholder="Search artists…"
sortOptions={SORT_OPTIONS}
sortAriaLabel="Sort artists"
/>
</div>
<section className={CARD_GRID_CLASSES_4}>
{artists.map((artist) => (
<ArtistCard
key={artist._id}
name={artist.name}
subtitle={artist.role}
image={artist.image}
href={artist.slug ? `/artist/${artist.slug}` : "/artists/"}
/>
))}
</section>
{totalPages > 1 ? (
<PaginationNav page={safePage} totalPages={totalPages} buildHref={buildHref} />
) : null}
</main>
);
}

View file

@ -0,0 +1,19 @@
"use client";
export default function BlogError({ reset }: { error: Error; reset: () => void }) {
return (
<main className="mx-auto my-auto max-w-275 px-6 py-12 font-silka md:px-8 md:py-16">
<h1 className="mb-4 font-argesta text-3xl">Something went wrong</h1>
<p className="mb-6 text-lightsec dark:text-darksec">
We couldn&apos;t load the blog posts. Please try again.
</p>
<button
type="button"
onClick={reset}
className="rounded-xl border border-lightline px-6 py-3 text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue dark:border-darkline dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white"
>
Try again
</button>
</main>
);
}

View file

@ -0,0 +1,12 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

175
app/blog/(archive)/page.tsx Normal file
View file

@ -0,0 +1,175 @@
import type { Metadata } from "next";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { BlogCard, type BlogCardData } from "@/components/blog/BlogCard";
import { AnimatedText } from "@/components/AnimatedText";
import { SearchBar } from "@/components/SearchBar";
import { PaginationNav } from "@/components/PaginationNav";
import { CARD_GRID_CLASSES_4 } from "@/lib/constants";
import { PAGE_SIZE, clampInt, normalizeQuery, groqLikeParam } from "@/lib/listHelpers";
import type { SortOption } from "@/components/SortDropdown";
import type { FilterGroup } from "@/components/FilterDropdown";
export const revalidate = 86400;
type SortMode = "dateDesc" | "dateAsc";
const CATEGORY_OPTIONS = [
{ value: "News", label: "News" },
{ value: "Behind the Scenes", label: "Behind the Scenes" },
{ value: "Music History", label: "Music History" },
{ value: "Tech Talk", label: "Tech Talk" },
];
const FILTER_GROUPS: FilterGroup[] = [
{ label: "Category", param: "category", options: CATEGORY_OPTIONS },
];
const VALID_CATEGORIES = new Set(CATEGORY_OPTIONS.map((o) => o.value));
function parseFilterParam(raw: string | undefined, valid: Set<string>): string[] {
if (!raw) return [];
return raw.split(",").filter((v) => valid.has(v));
}
const SORT_OPTIONS: SortOption<SortMode>[] = [
{ value: "dateDesc", label: "Date", iconDirection: "desc" },
{ value: "dateAsc", label: "Date", iconDirection: "asc" },
];
function normalizeSort(s: string | undefined): SortMode {
return s === "dateAsc" ? "dateAsc" : "dateDesc";
}
const BLOG_FILTER_CLAUSE = `
_type == "blog" &&
(
$q == "" ||
title match $qPattern ||
subtitle match $qPattern ||
author match $qPattern
) &&
(count($categories) == 0 || category in $categories)
`;
const BLOG_PROJECTION = `{
_id,
title,
subtitle,
author,
publishDate,
category,
"slug": slug.current,
featuredImage
}`;
const BLOGS_BY_DATE_DESC_QUERY = defineQuery(`
*[
${BLOG_FILTER_CLAUSE}
]
| order(coalesce(publishDate, "0000-01-01") desc, lower(title) asc)
[$start...$end]${BLOG_PROJECTION}
`);
const BLOGS_BY_DATE_ASC_QUERY = defineQuery(`
*[
${BLOG_FILTER_CLAUSE}
]
| order(coalesce(publishDate, "9999-12-31") asc, lower(title) asc)
[$start...$end]${BLOG_PROJECTION}
`);
const BLOGS_COUNT_QUERY = defineQuery(`
count(*[
${BLOG_FILTER_CLAUSE}
])
`);
export const metadata: Metadata = {
title: "Blog",
description:
"News, behind-the-scenes stories, and insights from TRPTK.",
};
export default async function BlogArchivePage({
searchParams,
}: {
searchParams: Promise<{
page?: string;
q?: string;
sort?: string;
category?: string;
}>;
}) {
const sp = await searchParams;
const q = normalizeQuery(sp.q);
const sort = normalizeSort(sp.sort);
const page = clampInt(sp.page, 1, 1, 9999);
const categories = parseFilterParam(sp.category, VALID_CATEGORIES);
const start = (page - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
const qPattern = q ? `${groqLikeParam(q)}*` : "";
const listQuery = sort === "dateAsc" ? BLOGS_BY_DATE_ASC_QUERY : BLOGS_BY_DATE_DESC_QUERY;
const queryParams = { start, end, q, qPattern, categories };
const [blogs, total] = await Promise.all([
sanity.fetch<BlogCardData[]>(listQuery, queryParams),
sanity.fetch<number>(BLOGS_COUNT_QUERY, queryParams),
]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const initialFilters: Record<string, string[]> = {};
if (categories.length) initialFilters.category = categories;
const buildHref = (nextPage: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (sort !== "dateDesc") params.set("sort", sort);
if (categories.length) params.set("category", categories.join(","));
if (nextPage > 1) params.set("page", String(nextPage));
const qs = params.toString();
return qs ? `/blog?${qs}` : "/blog";
};
return (
<main className="mx-auto my-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
<div className="mb-10">
<AnimatedText text="Blog" as="h1" className="font-argesta text-3xl" />
<p className="mt-2 text-base text-lightsec dark:text-darksec">
{total ? `${total} post(s)` : "No posts found."}
</p>
</div>
<div className="mb-12">
<SearchBar
initialQuery={q}
initialSort={sort}
initialPage={safePage}
defaultSort="dateDesc"
placeholder="Search posts…"
sortOptions={SORT_OPTIONS}
sortAriaLabel="Sort posts"
filterGroups={FILTER_GROUPS}
initialFilters={initialFilters}
/>
</div>
<section className={CARD_GRID_CLASSES_4}>
{blogs.map((blog) => (
<BlogCard key={blog._id} blog={blog} />
))}
</section>
{totalPages > 1 ? (
<PaginationNav page={safePage} totalPages={totalPages} buildHref={buildHref} />
) : null}
</main>
);
}

View file

@ -0,0 +1,12 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

305
app/blog/[slug]/page.tsx Normal file
View file

@ -0,0 +1,305 @@
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 { ReleaseCover } from "@/components/release/ReleaseCover";
import { AnimatedText } from "@/components/AnimatedText";
import { urlFor } from "@/lib/sanityImage";
import { portableTextComponents } from "@/lib/portableTextComponents";
import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants";
import { Breadcrumb } from "@/components/Breadcrumb";
import { ArtistCardCompact } from "@/components/artist/ArtistCardCompact";
import { ReleaseCard, type ReleaseCardData } from "@/components/release/ReleaseCard";
import { formatYears, type ArtistCardData, type ComposerCardData } from "@/components/artist/types";
import type { SanityImageSource } from "@sanity/image-url";
export const dynamicParams = true;
export const revalidate = 86400;
type BlogDetail = {
title?: string;
subtitle?: string;
slug?: string;
author?: string;
publishDate?: string;
category?: string;
featuredImage?: SanityImageSource;
content?: any;
releases?: ReleaseCardData[];
artists?: ArtistCardData[];
composers?: ComposerCardData[];
};
const BLOG_DETAIL_QUERY = defineQuery(`
*[_type == "blog" && slug.current == $slug][0]{
title,
subtitle,
"slug": slug.current,
author,
publishDate,
category,
featuredImage,
content[]{
...,
_type == "image" => {
...,
asset->
}
},
"releases": releases[]->{
_id,
name,
albumArtist,
catalogNo,
releaseDate,
"slug": slug.current,
albumCover
},
"artists": artists[]->{
_id,
name,
role,
"slug": slug.current,
image
},
"composers": composers[]->{
_id,
name,
sortKey,
"slug": slug.current,
birthYear,
deathYear,
image
}
}
`);
const getBlog = cache(async (slug: string) => {
try {
return await sanity.fetch<BlogDetail>(BLOG_DETAIL_QUERY, { slug });
} catch (error) {
console.error("Failed to fetch blog:", error);
return null;
}
});
const BLOG_SLUGS_QUERY = defineQuery(
`*[_type == "blog" && defined(slug.current)]{ "slug": slug.current }`,
);
export async function generateStaticParams() {
const slugs = await sanity.fetch<Array<{ slug: string }>>(BLOG_SLUGS_QUERY);
return slugs.map((b) => ({ slug: b.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug: rawSlug } = await params;
const slug = rawSlug.toLowerCase();
const blog = await getBlog(slug);
if (!blog) notFound();
const description = blog.subtitle ?? `${blog.title} • Read more on the TRPTK blog.`;
const ogImage = blog.featuredImage
? urlFor(blog.featuredImage).width(1200).height(630).url()
: undefined;
return {
title: `${blog.title}`,
description,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
title: blog.title,
description,
type: "article",
...(blog.publishDate && { publishedTime: blog.publishDate }),
...(blog.author && { authors: [blog.author] }),
...(ogImage && {
images: [{ url: ogImage, width: 1200, height: 630, alt: blog.title }],
}),
},
twitter: {
card: "summary_large_image",
title: blog.title,
description,
...(ogImage && { images: [ogImage] }),
},
};
}
function formatPublishDate(dateString?: string) {
if (!dateString) return null;
return new Intl.DateTimeFormat("en-US", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(dateString));
}
export default async function BlogPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!slug) notFound();
const normalizedSlug = slug.toLowerCase();
if (slug !== normalizedSlug) {
redirect(`/blog/${normalizedSlug}`);
}
const blog = await getBlog(slug);
if (!blog) notFound();
const displayDate = formatPublishDate(blog.publishDate);
const artists = blog.artists ?? [];
const composers = (blog.composers ?? []).sort((a, b) =>
(a.sortKey ?? "").localeCompare(b.sortKey ?? ""),
);
const releases = blog.releases ?? [];
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: blog.title,
url: `https://trptk.com/blog/${slug}`,
...(blog.publishDate && { datePublished: blog.publishDate }),
...(blog.author && {
author: { "@type": "Person", name: blog.author },
}),
...(blog.featuredImage && {
image: urlFor(blog.featuredImage).width(1200).url(),
}),
...(blog.subtitle && { description: blog.subtitle }),
publisher: {
"@type": "Organization",
name: "TRPTK",
url: "https://trptk.com",
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<main className="mx-auto my-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
{/* Hero */}
<div className="mx-auto grid grid-cols-1 gap-10 sm:grid-cols-2 sm:gap-8 md:gap-12 lg:gap-20">
<div className="flex-1 content-center">
<ReleaseCover
src={blog.featuredImage ? urlFor(blog.featuredImage).url() : ARTIST_PLACEHOLDER_SRC}
alt={blog.title ? `Featured image for ${blog.title}` : "Blog post image"}
/>
</div>
<div className="flex-1 content-center">
<Breadcrumb
crumbs={[
{ label: "Blog", href: "/blog" },
...(blog.category ? [{ label: blog.category, href: `/blog?category=${encodeURIComponent(blog.category)}` }] : []),
]}
/>
<AnimatedText
text={blog.title ?? "Untitled post"}
as="h1"
className="mb-2 font-argesta text-3xl break-words"
/>
{blog.subtitle && (
<AnimatedText
text={blog.subtitle}
as="h2"
className="mb-4 font-silka text-base break-words text-lightsec dark:text-darksec"
delay={0.25}
/>
)}
{(blog.author || displayDate) && (
<p className="text-sm text-lightsec dark:text-darksec">
{blog.author && <>Posted by {blog.author}</>}
{blog.author && displayDate && <> on </>}
{!blog.author && displayDate && <>Posted on </>}
{displayDate}
</p>
)}
</div>
</div>
{/* Content */}
{blog.content && (
<section className="mx-auto mt-16 max-w-200 sm:mt-20">
<article className="prose max-w-none text-lighttext dark:text-darktext dark:prose-invert prose-h2:!mt-[3em] prose-h2:font-silkasb prose-h2:text-base prose-strong:font-silkasb">
<PortableText value={blog.content} components={portableTextComponents} />
</article>
</section>
)}
{/* Related releases */}
{releases.length > 0 && (
<section className="mx-auto mt-16 max-w-200 sm:mt-20">
<AnimatedText text="Related releases" as="h2" className="mb-4 font-argesta text-2xl" />
<div className="grid grid-cols-2 gap-6 sm:gap-8 md:grid-cols-3">
{releases.map((r) => (
<ReleaseCard key={r._id} release={r} />
))}
</div>
</section>
)}
{/* Related artists & composers */}
{(artists.length > 0 || composers.length > 0) && (
<section className="mx-auto mt-16 grid max-w-200 grid-cols-1 gap-16 sm:mt-20 md:grid-cols-2 md:gap-12 lg:gap-20">
{artists.length > 0 && (
<div>
<AnimatedText
text="Related artists"
as="h2"
className="mb-4 font-argesta text-2xl"
/>
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-1">
{artists.map((artist) => (
<ArtistCardCompact
key={artist._id}
name={artist.name}
subtitle={artist.role}
image={artist.image}
href={artist.slug ? `/artist/${artist.slug}` : "/artists/"}
/>
))}
</ul>
</div>
)}
{composers.length > 0 && (
<div>
<AnimatedText
text="Related composers"
as="h2"
className="mb-4 font-argesta text-2xl"
/>
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-1">
{composers.map((composer) => (
<ArtistCardCompact
key={composer._id}
name={composer.name}
subtitle={formatYears(composer.birthYear, composer.deathYear)}
image={composer.image}
href={composer.slug ? `/composer/${composer.slug}` : "/composers/"}
label="Composer"
/>
))}
</ul>
</div>
)}
</section>
)}
</main>
</>
);
}

18
app/checkout/layout.tsx Normal file
View file

@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export const metadata: Metadata = {
title: "Checkout",
robots: { index: false, follow: false },
};
export default function CheckoutLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

806
app/checkout/page.tsx Normal file
View file

@ -0,0 +1,806 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useCart } from "@/components/cart/CartContext";
import { useAuth } from "@/components/auth/AuthContext";
import { hasPhysicalItems, hasDigitalItems } from "@/lib/cartUtils";
import type { MedusaShippingOption } from "@/lib/medusa";
import { FORMAT_GROUPS } from "@/lib/variants";
import { CountrySelect } from "@/components/CountrySelect";
import { IconButton } from "@/components/IconButton";
import { IoArrowForwardOutline, IoTimeOutline } from "react-icons/io5";
import type { MedusaLineItem } from "@/lib/medusa";
import { AuthForm } from "@/components/auth/AuthForm";
import { ArrowLink, ArrowButton } from "@/components/ArrowLink";
// ── Helpers (shared with CartDrawer) ────────────────────────────────
const suffixToLabel: Record<string, string> = {};
for (const group of FORMAT_GROUPS) {
for (const v of group.variants ?? []) suffixToLabel[v.suffix] = v.title;
for (const sg of group.subgroups ?? []) {
for (const v of sg.variants) suffixToLabel[v.suffix] = v.title;
}
}
function variantLabel(item: MedusaLineItem): string {
const sku = item.variant?.sku;
if (sku) {
const suffix = sku.split("_").pop()?.toUpperCase();
if (suffix && suffixToLabel[suffix]) return suffixToLabel[suffix];
}
return item.variant?.title ?? item.title;
}
function formatPrice(amount: number, currencyCode: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
minimumFractionDigits: 2,
}).format(amount);
}
async function apiPost(url: string, body: unknown) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
return res.json();
}
// ── Component ───────────────────────────────────────────────────────
export default function CheckoutPage() {
const router = useRouter();
const { cart, isLoading, applyPromo, removePromo } = useCart();
const { customer, isAuthenticated, isLoading: authLoading } = useAuth();
// Billing address (always collected)
const [email, setEmail] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [address1, setAddress1] = useState("");
const [address2, setAddress2] = useState("");
const [city, setCity] = useState("");
const [province, setProvince] = useState("");
const [postalCode, setPostalCode] = useState("");
const [countryCode, setCountryCode] = useState("nl");
const [phone, setPhone] = useState("");
// Separate shipping address (only when "Ship to different address" is checked)
const [shipToDifferent, setShipToDifferent] = useState(false);
const [shipFirstName, setShipFirstName] = useState("");
const [shipLastName, setShipLastName] = useState("");
const [shipAddress1, setShipAddress1] = useState("");
const [shipAddress2, setShipAddress2] = useState("");
const [shipCity, setShipCity] = useState("");
const [shipProvince, setShipProvince] = useState("");
const [shipPostalCode, setShipPostalCode] = useState("");
const [shipCountryCode, setShipCountryCode] = useState("nl");
const [shipPhone, setShipPhone] = useState("");
// Shipping
const [shippingOptions, setShippingOptions] = useState<MedusaShippingOption[]>([]);
const [selectedShipping, setSelectedShipping] = useState<string | null>(null);
const [loadingShipping, setLoadingShipping] = useState(false);
// Promo code
const [promoCode, setPromoCode] = useState("");
const [promoError, setPromoError] = useState<string | null>(null);
const [promoLoading, setPromoLoading] = useState(false);
// Terms & conditions
const [termsAccepted, setTermsAccepted] = useState(false);
// Inline login toggle
const [showLogin, setShowLogin] = useState(false);
// Submission
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const needsShipping = cart ? hasPhysicalItems(cart) : false;
const hasDigital = cart ? hasDigitalItems(cart) : false;
// Pre-fill email and address from customer account
useEffect(() => {
if (!isAuthenticated || !customer) return;
if (customer.email && !email) setEmail(customer.email);
// Fetch saved addresses and pre-fill the first one
async function prefillAddress() {
try {
const res = await fetch("/api/account/addresses");
if (!res.ok) return;
const data = await res.json();
const addr = data.addresses?.[0];
if (!addr) return;
// Only pre-fill if billing fields are still empty
if (!firstName && !lastName && !address1) {
setFirstName(addr.first_name ?? "");
setLastName(addr.last_name ?? "");
setAddress1(addr.address_1 ?? "");
setAddress2(addr.address_2 ?? "");
setCity(addr.city ?? "");
setProvince(addr.province ?? "");
setPostalCode(addr.postal_code ?? "");
setCountryCode(addr.country_code ?? "nl");
setPhone(addr.phone ?? "");
}
} catch {
// Non-critical — user can fill in manually
}
}
prefillAddress();
}, [isAuthenticated, customer]); // eslint-disable-line react-hooks/exhaustive-deps
// Build address objects
const billingAddress = {
first_name: firstName,
last_name: lastName,
address_1: address1,
address_2: address2 || undefined,
city,
province: province || undefined,
postal_code: postalCode,
country_code: countryCode,
phone: phone || undefined,
};
const effectiveShippingAddress =
needsShipping && shipToDifferent
? {
first_name: shipFirstName,
last_name: shipLastName,
address_1: shipAddress1,
address_2: shipAddress2 || undefined,
city: shipCity,
province: shipProvince || undefined,
postal_code: shipPostalCode,
country_code: shipCountryCode,
phone: shipPhone || undefined,
}
: billingAddress;
// Redirect if cart is empty (after loading)
useEffect(() => {
if (!isLoading && (!cart || cart.items.length === 0)) {
router.replace("/");
}
}, [isLoading, cart, router]);
// Auto-fetch shipping options when required address fields are filled
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shipAddrFields =
needsShipping && shipToDifferent
? {
fn: shipFirstName,
ln: shipLastName,
a1: shipAddress1,
c: shipCity,
pc: shipPostalCode,
cc: shipCountryCode,
}
: { fn: firstName, ln: lastName, a1: address1, c: city, pc: postalCode, cc: countryCode };
const addressComplete =
needsShipping &&
!!shipAddrFields.fn &&
!!shipAddrFields.ln &&
!!shipAddrFields.a1 &&
!!shipAddrFields.c &&
!!shipAddrFields.pc &&
!!shipAddrFields.cc;
useEffect(() => {
if (!addressComplete || !cart?.id) {
setShippingOptions([]);
setSelectedShipping(null);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setLoadingShipping(true);
setShippingOptions([]);
setSelectedShipping(null);
try {
// Update cart with address so Medusa knows the destination
await apiPost("/api/checkout/update", {
cartId: cart.id,
email: email || undefined,
shipping_address: effectiveShippingAddress,
});
const res = await fetch(`/api/checkout/shipping-options?cartId=${cart.id}`);
if (res.ok) {
const options: MedusaShippingOption[] = await res.json();
setShippingOptions(options);
if (options.length > 0) setSelectedShipping(options[0].id);
}
} catch (e) {
console.error("Failed to fetch shipping options:", e);
} finally {
setLoadingShipping(false);
}
}, 800);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
cart?.id,
addressComplete,
shipAddrFields.fn,
shipAddrFields.ln,
shipAddrFields.a1,
shipAddrFields.c,
shipAddrFields.pc,
shipAddrFields.cc,
email,
]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!cart?.id) return;
setSubmitting(true);
setError(null);
try {
// 1. Update cart with email + addresses
const updateBody: Record<string, unknown> = {
cartId: cart.id,
email,
billing_address: billingAddress,
shipping_address: effectiveShippingAddress,
};
await apiPost("/api/checkout/update", updateBody);
// 2. Add shipping method
if (needsShipping && selectedShipping) {
await apiPost("/api/checkout/shipping-method", {
cartId: cart.id,
optionId: selectedShipping,
});
} else if (!needsShipping) {
// Digital-only: auto-select the first (free) shipping option
const shipRes = await fetch(`/api/checkout/shipping-options?cartId=${cart.id}`);
if (shipRes.ok) {
const options: MedusaShippingOption[] = await shipRes.json();
if (options.length > 0) {
await apiPost("/api/checkout/shipping-method", {
cartId: cart.id,
optionId: options[0].id,
});
}
}
}
// 3. Create payment collection + Mollie session
const paymentCollection = await apiPost("/api/checkout/payment", {
cartId: cart.id,
});
// 4. Find the Mollie redirect URL from the payment session
const session = paymentCollection.payment_sessions?.[0];
const redirectUrl =
session?.data?.session_url ??
session?.data?.checkout_url ??
session?.data?._links?.checkout?.href;
if (redirectUrl) {
// Validate the payment redirect points to a trusted Mollie domain
try {
const parsedUrl = new URL(redirectUrl);
if (!parsedUrl.hostname.endsWith("mollie.com")) {
throw new Error("Untrusted payment redirect URL");
}
} catch {
throw new Error("Invalid payment redirect URL received.");
}
window.location.href = redirectUrl;
} else {
throw new Error("No payment redirect URL received. Check your Mollie configuration.");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
setSubmitting(false);
}
};
if (isLoading || authLoading || !cart || cart.items.length === 0) {
return (
<main className="flex min-h-[60vh] items-center justify-center">
<p className="text-lightsec dark:text-darksec">Loading</p>
</main>
);
}
const currencyCode = cart.currency_code ?? "eur";
const inputClass =
"no-ring w-full rounded-xl border border-lightline px-6 py-3 shadow-lg text-lighttext transition-all duration-200 ease-in-out placeholder:text-lightsec hover:border-lightline-hover focus:border-lightline-focus dark:border-darkline dark:text-darktext dark:placeholder:text-darksec dark:hover:border-darkline-hover dark:focus:border-darkline-focus";
const billingValid = !!firstName && !!lastName && !!address1 && !!city && !!postalCode;
const shipValid =
!shipToDifferent ||
(!!shipFirstName && !!shipLastName && !!shipAddress1 && !!shipCity && !!shipPostalCode);
const needsLogin = hasDigital && !isAuthenticated;
const formDisabled =
submitting ||
!email ||
!billingValid ||
!termsAccepted ||
needsLogin ||
(needsShipping && (!shipValid || !selectedShipping));
return (
<main className="mx-auto max-w-300 px-6 py-12 md:px-8 md:py-16">
<h1 className="font-argesta text-3xl">Checkout</h1>
<form onSubmit={handleSubmit} className="mt-10 grid gap-12 lg:grid-cols-[1fr_380px]">
{/* ── Left: Form ── */}
<div className="flex flex-col gap-14">
{/* Contact */}
<section>
<h2 className="mb-4 font-silkasb text-lg">Contact</h2>
{isAuthenticated ? (
<p className="mb-3 text-sm text-lightsec dark:text-darksec">
Signed in as{" "}
<span className="font-silkasb text-lighttext dark:text-darktext">
{customer?.email}
</span>
</p>
) : (
<div className="mb-3">
<p className="text-sm text-lightsec dark:text-darksec">
{hasDigital ? (
<>
An account is required for digital downloads.{" "}
<ArrowButton
onClick={() => setShowLogin(!showLogin)}
className="text-trptkblue dark:text-white"
>
{showLogin ? "Hide" : "Sign in or create an account"}
</ArrowButton>
</>
) : (
<>
Have an account?{" "}
<ArrowButton
onClick={() => setShowLogin(!showLogin)}
className="text-trptkblue dark:text-white"
>
{showLogin ? "Continue as guest" : "Log in"}
</ArrowButton>
</>
)}
</p>
{showLogin && (
<div className="mt-4">
<AuthForm compact onSuccess={() => setShowLogin(false)} />
</div>
)}
</div>
)}
{!showLogin && (
<input
type="email"
required
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
)}
</section>
{/* Billing Address (always shown) + Ship toggle */}
<div className="flex flex-col gap-6">
<section>
<h2 className="mb-4 font-silkasb text-lg">Billing Address</h2>
<div className="grid gap-3 sm:grid-cols-2">
<input
type="text"
required
placeholder="First name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Last name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className={inputClass}
/>
</div>
<input
type="text"
required
placeholder="Address"
value={address1}
onChange={(e) => setAddress1(e.target.value)}
className={inputClass + " mt-3"}
/>
<input
type="text"
placeholder="Apartment, suite, etc. (optional)"
value={address2}
onChange={(e) => setAddress2(e.target.value)}
className={inputClass + " mt-3"}
/>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<input
type="text"
required
placeholder="City"
value={city}
onChange={(e) => setCity(e.target.value)}
className={inputClass}
/>
<input
type="text"
placeholder="Province / State"
value={province}
onChange={(e) => setProvince(e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Postal code"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
className={inputClass}
/>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<CountrySelect
value={countryCode}
onChange={setCountryCode}
className={inputClass}
/>
<input
type="tel"
placeholder="Phone (optional)"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className={inputClass}
/>
</div>
</section>
{/* Ship to different address (only for physical items) */}
{needsShipping && (
<>
<label className="flex cursor-pointer items-center gap-3 text-sm">
<input
type="checkbox"
checked={shipToDifferent}
onChange={(e) => setShipToDifferent(e.target.checked)}
className="h-4 w-4 rounded border-lightline accent-trptkblue dark:border-darkline"
/>
Ship to a different address?
</label>
{shipToDifferent && (
<section>
<h2 className="mb-4 font-silkasb text-lg">Shipping Address</h2>
<div className="grid gap-3 sm:grid-cols-2">
<input
type="text"
required
placeholder="First name"
value={shipFirstName}
onChange={(e) => setShipFirstName(e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Last name"
value={shipLastName}
onChange={(e) => setShipLastName(e.target.value)}
className={inputClass}
/>
</div>
<input
type="text"
required
placeholder="Address"
value={shipAddress1}
onChange={(e) => setShipAddress1(e.target.value)}
className={inputClass + " mt-3"}
/>
<input
type="text"
placeholder="Apartment, suite, etc. (optional)"
value={shipAddress2}
onChange={(e) => setShipAddress2(e.target.value)}
className={inputClass + " mt-3"}
/>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<input
type="text"
required
placeholder="City"
value={shipCity}
onChange={(e) => setShipCity(e.target.value)}
className={inputClass}
/>
<input
type="text"
placeholder="Province / State"
value={shipProvince}
onChange={(e) => setShipProvince(e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Postal code"
value={shipPostalCode}
onChange={(e) => setShipPostalCode(e.target.value)}
className={inputClass}
/>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<CountrySelect
value={shipCountryCode}
onChange={setShipCountryCode}
className={inputClass}
/>
<input
type="tel"
placeholder="Phone (optional)"
value={shipPhone}
onChange={(e) => setShipPhone(e.target.value)}
className={inputClass}
/>
</div>
</section>
)}
{loadingShipping && (
<p className="text-sm text-lightsec dark:text-darksec">
Loading shipping options
</p>
)}
</>
)}
</div>
{/* Shipping Method (auto-selected, shown as info) */}
{needsShipping &&
selectedShipping &&
shippingOptions.length > 0 &&
(() => {
const option = shippingOptions.find((o) => o.id === selectedShipping);
if (!option) return null;
return (
<section>
<h2 className="mb-4 font-silkasb text-lg">Shipping</h2>
<div className="flex items-center justify-between rounded-xl border border-lightline p-4 text-sm dark:border-darkline">
<span>{option.name}</span>
<span className="font-silkasb">
{option.amount === 0 ? "Free" : formatPrice(option.amount, currencyCode)}
</span>
</div>
</section>
);
})()}
{/* Error */}
{error && (
<div className="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400">
{error}
</div>
)}
{/* Terms & Submit */}
<div className="flex flex-col gap-4">
<label className="flex cursor-pointer items-start gap-3 text-sm">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-lightline accent-trptkblue dark:border-darkline"
/>
<span className="text-lightsec dark:text-darksec">
I have read and agree to the{" "}
<ArrowLink
href="/terms-conditions"
target="_blank"
className="text-trptkblue dark:text-white"
>
Terms &amp; Conditions
</ArrowLink>
</span>
</label>
<button
type="submit"
disabled={formDisabled}
className="w-full rounded-xl bg-trptkblue px-4 py-4 font-silkasb text-sm text-white shadow-lg transition-all duration-200 ease-in-out hover:opacity-90 disabled:pointer-events-none disabled:opacity-50 dark:bg-white dark:text-lighttext"
>
{submitting ? "Redirecting to payment…" : "Pay with Mollie"}
</button>
</div>
</div>
{/* ── Right: Order Summary ── */}
<aside className="order-first lg:order-last">
<h2 className="mb-4 font-silkasb text-lg">Order Summary</h2>
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
{cart.items.map((item) => {
const content = (
<>
<div className="relative aspect-square w-21 flex-shrink-0 overflow-hidden rounded-xl bg-lightline dark:bg-darkline">
{item.thumbnail ? (
<Image
src={item.thumbnail}
alt={item.product_title ?? item.title}
fill
sizes="84px"
className="object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-lightsec dark:text-darksec">
No img
</div>
)}
</div>
<div className="min-w-0 flex-1 py-3 pr-3">
<p className="truncate font-silkasb text-sm">
{item.product_title ?? item.title}
</p>
<p className="truncate text-xs text-lightsec dark:text-darksec">
{variantLabel(item)} &times; {item.quantity}
</p>
<p className="mt-1 font-silkasb text-sm">
{formatPrice(item.total, currencyCode)}
</p>
</div>
</>
);
return (
<li
key={item.id}
className="overflow-hidden rounded-xl shadow-lg ring-1 ring-lightline transition-all duration-200 ease-in-out hover:ring-lightline-hover dark:ring-darkline dark:hover:ring-darkline-hover"
>
{item.product_handle ? (
<Link
href={`/release/${item.product_handle}`}
className="flex items-center gap-3"
>
{content}
</Link>
) : (
<div className="flex items-center gap-3">{content}</div>
)}
</li>
);
})}
</ul>
<div className="mt-6 space-y-2 pt-4 text-sm">
<div className="flex justify-between">
<span className="text-lightsec dark:text-darksec">Subtotal</span>
<span>{formatPrice(cart.subtotal, currencyCode)}</span>
</div>
{needsShipping && (
<div className="flex justify-between">
<span className="text-lightsec dark:text-darksec">Shipping</span>
<span>
{cart.shipping_total != null
? formatPrice(cart.shipping_total, currencyCode)
: "Calculated at next step"}
</span>
</div>
)}
{(cart.discount_total ?? 0) > 0 && (
<div className="flex justify-between">
<span className="text-lightsec dark:text-darksec">Discount</span>
<span className="text-green-600 dark:text-green-400">
&minus;{formatPrice(cart.discount_total!, currencyCode)}
</span>
</div>
)}
<div className="flex justify-between pt-2 font-silkasb">
<span>Total</span>
<span>{formatPrice(cart.total, currencyCode)}</span>
</div>
</div>
{/* Promo Code */}
<div className="mt-8 pt-4">
<div className="flex gap-2">
<input
type="text"
placeholder="Promo code"
value={promoCode}
onChange={(e) => {
setPromoCode(e.target.value);
setPromoError(null);
}}
className={inputClass + " flex-1"}
/>
<IconButton
disabled={promoLoading || !promoCode.trim()}
onClick={async () => {
setPromoLoading(true);
setPromoError(null);
try {
await applyPromo(promoCode.trim());
setPromoCode("");
} catch (e) {
setPromoError(e instanceof Error ? e.message : "Invalid promo code");
} finally {
setPromoLoading(false);
}
}}
className="flex-shrink-0"
>
{promoLoading ? (
<IoTimeOutline className="animate-spin" />
) : (
<IoArrowForwardOutline />
)}
</IconButton>
</div>
{promoError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{promoError}</p>
)}
{(cart.promotions?.length ?? 0) > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{cart.promotions!.map((promo) => (
<span
key={promo.id}
className="inline-flex items-center gap-1.5 rounded-lg bg-green-50 px-3 py-1.5 font-silkasb text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400"
>
{promo.code ?? promo.id}
<button
type="button"
onClick={async () => {
try {
await removePromo(promo.code ?? promo.id);
} catch (e) {
setPromoError(e instanceof Error ? e.message : "Failed to remove code");
}
}}
className="ml-0.5 text-green-500 transition-colors hover:text-green-800 dark:hover:text-green-200"
aria-label={`Remove promo ${promo.code ?? promo.id}`}
>
&times;
</button>
</span>
))}
</div>
)}
</div>
</aside>
</form>
</main>
);
}

View file

@ -0,0 +1,248 @@
"use client";
import { useEffect, useState, useRef } from "react";
import Link from "next/link";
import { useCart } from "@/components/cart/CartContext";
import type { MedusaOrder } from "@/lib/medusa";
function formatPrice(amount: number, currencyCode: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
minimumFractionDigits: 2,
}).format(amount);
}
interface DownloadItem {
product_title: string;
variant_title: string;
sku: string;
download_url: string;
}
interface UpcomingItem {
product_title: string;
variant_title: string;
sku: string;
release_date: string;
}
export default function CheckoutReturnPage() {
const { cart, resetCart } = useCart();
const [order, setOrder] = useState<MedusaOrder | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
const [upcoming, setUpcoming] = useState<UpcomingItem[]>([]);
const attempted = useRef(false);
useEffect(() => {
if (attempted.current) return;
if (!cart?.id) {
// Cart context still loading — wait
return;
}
attempted.current = true;
async function completeOrder() {
try {
const res = await fetch("/api/checkout/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cartId: cart!.id }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Failed to complete order");
}
const result = await res.json();
if (result.type === "order" && result.order) {
setOrder(result.order);
resetCart();
// Fetch download links for digital items
try {
const dlRes = await fetch(
`/api/checkout/downloads?orderId=${result.order.id}`,
);
if (dlRes.ok) {
const dlData = await dlRes.json();
if (dlData.downloads?.length) setDownloads(dlData.downloads);
if (dlData.upcoming?.length) setUpcoming(dlData.upcoming);
}
} catch {
// Download links are non-critical — don't block the page
}
} else {
throw new Error("Payment was not completed. Please try again.");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
}
completeOrder();
}, [cart?.id, resetCart]);
if (loading) {
return (
<main className="flex min-h-[60vh] items-center justify-center">
<p className="text-lightsec dark:text-darksec">Completing your order</p>
</main>
);
}
if (error) {
return (
<main className="mx-auto max-w-lg px-6 py-16 text-center">
<h1 className="font-argesta text-3xl">Something went wrong</h1>
<p className="mt-4 text-lightsec dark:text-darksec">{error}</p>
<div className="mt-8 flex justify-center gap-4">
<Link
href="/checkout"
className="rounded-xl border border-lightline px-6 py-3 text-sm transition-all duration-200 hover:border-lightline-hover hover:text-trptkblue dark:border-darkline dark:hover:border-darkline-hover dark:hover:text-white"
>
Try Again
</Link>
<Link
href="/"
className="rounded-xl bg-trptkblue px-6 py-3 text-sm font-silkasb text-white shadow-lg transition-all duration-200 hover:opacity-90 dark:bg-white dark:text-lighttext"
>
Go Home
</Link>
</div>
</main>
);
}
if (!order) return null;
const currencyCode = order.currency_code ?? "eur";
return (
<main className="mx-auto max-w-lg px-6 py-16">
<div className="text-center">
<h1 className="font-argesta text-3xl">Thank you!</h1>
<p className="mt-2 text-lightsec dark:text-darksec">
Your order has been confirmed.
</p>
</div>
<div className="mt-10 rounded-2xl border border-lightline p-6 dark:border-darkline">
<div className="flex items-center justify-between">
<span className="text-sm text-lightsec dark:text-darksec">Order number</span>
<span className="font-silkasb">#{order.display_id}</span>
</div>
{order.email && (
<div className="mt-2 flex items-center justify-between">
<span className="text-sm text-lightsec dark:text-darksec">Confirmation sent to</span>
<span className="text-sm">{order.email}</span>
</div>
)}
<div className="mt-6 space-y-2 border-t border-lightline pt-4 text-sm dark:border-darkline">
<div className="flex justify-between">
<span className="text-lightsec dark:text-darksec">Subtotal</span>
<span>{formatPrice(order.subtotal, currencyCode)}</span>
</div>
{order.shipping_total > 0 && (
<div className="flex justify-between">
<span className="text-lightsec dark:text-darksec">Shipping</span>
<span>{formatPrice(order.shipping_total, currencyCode)}</span>
</div>
)}
{order.tax_total > 0 && (
<div className="flex justify-between">
<span className="text-lightsec dark:text-darksec">Tax</span>
<span>{formatPrice(order.tax_total, currencyCode)}</span>
</div>
)}
<div className="flex justify-between border-t border-lightline pt-2 font-silkasb dark:border-darkline">
<span>Total</span>
<span>{formatPrice(order.total, currencyCode)}</span>
</div>
</div>
</div>
{/* Download links for digital purchases */}
{downloads.length > 0 && (
<div className="mt-6 rounded-2xl border border-lightline p-6 dark:border-darkline">
<h2 className="font-silkasb text-sm uppercase tracking-wider">
Your Downloads
</h2>
<div className="mt-4 space-y-3">
{downloads.map((dl) => (
<div key={dl.sku} className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="truncate text-sm font-silkasb">
{dl.product_title}
</p>
<p className="truncate text-xs text-lightsec dark:text-darksec">
{dl.variant_title}
</p>
</div>
<a
href={dl.download_url}
className="shrink-0 rounded-lg bg-trptkblue px-4 py-2 text-xs font-silkasb text-white shadow-lg transition-all duration-200 hover:opacity-90 dark:bg-white dark:text-lighttext"
>
Download
</a>
</div>
))}
</div>
<p className="mt-4 text-xs text-lightsec dark:text-darksec">
Download links expire after 5 minutes. A copy has been sent to your email.
</p>
</div>
)}
{/* Pre-order items not yet available */}
{upcoming.length > 0 && (
<div className="mt-6 rounded-2xl border border-lightline p-6 dark:border-darkline">
<h2 className="font-silkasb text-sm uppercase tracking-wider">
Upcoming Releases
</h2>
<div className="mt-4 space-y-3">
{upcoming.map((item) => (
<div key={item.sku} className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="truncate text-sm font-silkasb">
{item.product_title}
</p>
<p className="truncate text-xs text-lightsec dark:text-darksec">
{item.variant_title}
</p>
</div>
<span className="shrink-0 text-xs text-lightsec dark:text-darksec">
Available {new Date(item.release_date).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
</div>
))}
</div>
<p className="mt-4 text-xs text-lightsec dark:text-darksec">
You&apos;ll receive download links by email when these releases become available.
</p>
</div>
)}
<div className="mt-8 text-center">
<Link
href="/releases"
className="inline-block rounded-xl bg-trptkblue px-6 py-3 font-silkasb text-sm text-white shadow-lg transition-all duration-200 hover:opacity-90 dark:bg-white dark:text-lighttext"
>
Continue Shopping
</Link>
</div>
</main>
);
}

View file

@ -0,0 +1,12 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

View file

@ -0,0 +1,214 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { cache } from "react";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { ArtistTabs } from "@/components/artist/ArtistTabs";
import { ArtistReleasesTab, type ArtistRelease } from "@/components/artist/ArtistReleasesTab";
import { ReleaseCover } from "@/components/release/ReleaseCover";
import { AnimatedText } from "@/components/AnimatedText";
import { urlFor } from "@/lib/sanityImage";
import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants";
import { Breadcrumb } from "@/components/Breadcrumb";
export const dynamicParams = true;
export const revalidate = 86400;
type ComposedWork = {
title?: string;
slug?: string;
originalComposerName?: string;
arrangerName?: string;
};
type Composer = {
name?: string;
slug?: string;
birthYear?: number;
deathYear?: number;
bio?: any;
image?: any;
releases?: ArtistRelease[];
composedWorks?: ComposedWork[];
arrangedWorks?: ComposedWork[];
};
const COMPOSER_DETAIL_QUERY = defineQuery(`
*[_type == "composer" && slug.current == $slug][0]{
name,
birthYear,
deathYear,
"slug": slug.current,
bio,
image,
"releases": *[
_type == "release" &&
count(tracks[work->composer->slug.current == $slug]) > 0
]
| order(releaseDate desc, catalogNo desc) {
_id,
name,
albumArtist,
catalogNo,
"slug": slug.current,
releaseDate,
albumCover,
genre,
instrumentation
},
"composedWorks": *[
_type == "work" &&
composer->slug.current == $slug
]
| order(title asc, defined(arranger) asc, arranger->name asc) {
title,
"slug": slug.current,
"arrangerName": arranger->name
},
"arrangedWorks": *[
_type == "work" &&
arranger->slug.current == $slug
]
| order(coalesce(composer->sortKey, composer->name) asc, title asc) {
title,
"slug": slug.current,
"originalComposerName": composer->name,
"originalComposerSortKey": composer->sortKey
}
}
`);
const getComposer = cache(async (slug: string) => {
try {
const composer = await sanity.fetch<Composer>(COMPOSER_DETAIL_QUERY, { slug });
return composer;
} catch (error) {
console.error("Failed to fetch composer:", error);
return null;
}
});
const COMPOSER_SLUGS_QUERY = defineQuery(
`*[_type == "composer" && defined(slug.current)]{ "slug": slug.current }`,
);
export async function generateStaticParams() {
const slugs = await sanity.fetch<Array<{ slug: string }>>(COMPOSER_SLUGS_QUERY);
return slugs.map((c) => ({ slug: c.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug: rawSlug } = await params;
const slug = rawSlug.toLowerCase();
const composer = await getComposer(slug);
if (!composer) notFound();
const years = formatYears(composer.birthYear, composer.deathYear);
const description = `Explore ${composer.name}'s works${years ? ` (${years})` : ""} and releases on TRPTK.`;
const ogImage = composer.image
? urlFor(composer.image).width(1200).height(630).url()
: undefined;
return {
title: `${composer.name}${years ? ` (${years})` : ""}`,
description,
alternates: { canonical: `/composer/${slug}` },
openGraph: {
title: composer.name,
description: `Composer${years ? ` (${years})` : ""}${composer.composedWorks?.length ? ` - ${composer.composedWorks.length} works` : ""}`,
type: "profile",
...(ogImage && { images: [{ url: ogImage, width: 1200, height: 630, alt: composer.name }] }),
},
twitter: {
card: "summary_large_image",
title: composer.name,
description,
...(ogImage && { images: [ogImage] }),
},
};
}
const formatYears = (birthYear?: number, deathYear?: number): string => {
if (birthYear && deathYear) return `${birthYear}${deathYear}`;
if (birthYear) return `${birthYear}`;
return "";
};
export default async function ComposerPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!slug) notFound();
const normalizedSlug = slug.toLowerCase();
if (slug !== normalizedSlug) {
redirect(`/composer/${normalizedSlug}`);
}
const composer = await getComposer(slug);
if (!composer) notFound();
const displayName = composer.name ?? "";
const displayYears = formatYears(composer.birthYear, composer.deathYear);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Person",
name: composer.name,
url: `https://trptk.com/composer/${slug}`,
...(composer.birthYear && { birthDate: String(composer.birthYear) }),
...(composer.deathYear && { deathDate: String(composer.deathYear) }),
...(composer.image && { image: urlFor(composer.image).width(800).url() }),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<main className="mx-auto my-auto max-w-250 px-6 py-12 font-silka md:px-8 md:py-16">
<div className="mx-auto grid grid-cols-1 gap-10 sm:grid-cols-2 sm:gap-8 md:gap-12 lg:gap-20">
<div className="flex-1 content-center">
<ReleaseCover
src={composer.image ? urlFor(composer.image).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Photo of ${composer.name}`}
/>
</div>
<div className="flex-1 content-center">
<Breadcrumb crumbs={[{ label: "Composers", href: "/composers" }]} />
<AnimatedText
text={displayName}
as="h1"
className="mb-2 font-argesta text-3xl break-words"
/>
<AnimatedText
text={displayYears}
as="h2"
className="font-silka text-base break-words text-lightsec dark:text-darksec"
delay={0.25}
/>
</div>
</div>
<ArtistTabs
bio={composer.bio}
hasReleases={!!composer.releases?.length}
releasesTab={<ArtistReleasesTab releases={composer.releases ?? []} />}
composedWorks={composer.composedWorks}
arrangedWorks={composer.arrangedWorks}
/>
</main>
</>
);
}

19
app/composers/error.tsx Normal file
View file

@ -0,0 +1,19 @@
"use client";
export default function ComposersError({ reset }: { error: Error; reset: () => void }) {
return (
<main className="mx-auto my-auto max-w-275 px-6 py-12 font-silka md:px-8 md:py-16">
<h1 className="mb-4 font-argesta text-3xl">Something went wrong</h1>
<p className="mb-6 text-lightsec dark:text-darksec">
We couldn&apos;t load the composers. Please try again.
</p>
<button
type="button"
onClick={reset}
className="rounded-xl border border-lightline px-6 py-3 text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue dark:border-darkline dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white"
>
Try again
</button>
</main>
);
}

12
app/composers/layout.tsx Normal file
View file

@ -0,0 +1,12 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

180
app/composers/page.tsx Normal file
View file

@ -0,0 +1,180 @@
import type { Metadata } from "next";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { ArtistCard } from "@/components/artist/ArtistCard";
import { formatYears, type ComposerCardData } from "@/components/artist/types";
import { AnimatedText } from "@/components/AnimatedText";
import { SearchBar } from "@/components/SearchBar";
import { PaginationNav } from "@/components/PaginationNav";
import { CARD_GRID_CLASSES_4 } from "@/lib/constants";
import { PAGE_SIZE, clampInt, normalizeQuery, groqLikeParam } from "@/lib/listHelpers";
import type { SortOption } from "@/components/SortDropdown";
export const revalidate = 86400;
type SortMode = "name" | "sortKey" | "birthYearAsc" | "birthYearDesc";
const SORT_OPTIONS: SortOption<SortMode>[] = [
{ value: "name", label: "Sort by first name" },
{ value: "sortKey", label: "Sort by last name" },
{ value: "birthYearAsc", label: "Sort by birth year", iconDirection: "asc" },
{ value: "birthYearDesc", label: "Sort by birth year", iconDirection: "desc" },
];
function normalizeSort(s: string | undefined): SortMode {
switch (s) {
case "name":
case "sortKey":
case "birthYearAsc":
case "birthYearDesc":
return s;
default:
return "sortKey";
}
}
const COMPOSER_FILTER_CLAUSE = `
_type == "composer" &&
(
$q == "" ||
name match $qPattern ||
role match $qPattern
)
`;
const COMPOSER_PROJECTION = `{
_id,
name,
birthYear,
deathYear,
"slug": slug.current,
image
}`;
const COMPOSERS_BY_NAME_QUERY = defineQuery(`
*[
${COMPOSER_FILTER_CLAUSE}
]
| order(lower(name) asc)
[$start...$end]${COMPOSER_PROJECTION}
`);
const COMPOSERS_BY_SORT_KEY_QUERY = defineQuery(`
*[
${COMPOSER_FILTER_CLAUSE}
]
| order(lower(coalesce(sortKey, name)) asc, lower(name) asc)
[$start...$end]${COMPOSER_PROJECTION}
`);
const COMPOSERS_BY_BIRTH_YEAR_ASC_QUERY = defineQuery(`
*[
${COMPOSER_FILTER_CLAUSE}
]
| order(coalesce(birthYear, 999999) asc, lower(name) asc)
[$start...$end]${COMPOSER_PROJECTION}
`);
const COMPOSERS_BY_BIRTH_YEAR_DESC_QUERY = defineQuery(`
*[
${COMPOSER_FILTER_CLAUSE}
]
| order(coalesce(birthYear, -999999) desc, lower(name) asc)
[$start...$end]${COMPOSER_PROJECTION}
`);
const COMPOSERS_COUNT_QUERY = defineQuery(`
count(*[
${COMPOSER_FILTER_CLAUSE}
])
`);
export const metadata: Metadata = {
title: "Composers",
description:
"Discover composers featured on TRPTK recordings, from early music to contemporary works.",
};
export default async function ComposersPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; q?: string; sort?: string }>;
}) {
const sp = await searchParams;
const q = normalizeQuery(sp.q);
const sort = normalizeSort(sp.sort);
const page = clampInt(sp.page, 1, 1, 9999);
const start = (page - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
const qPattern = q ? `${groqLikeParam(q)}*` : "";
const listQuery =
sort === "sortKey"
? COMPOSERS_BY_SORT_KEY_QUERY
: sort === "birthYearAsc"
? COMPOSERS_BY_BIRTH_YEAR_ASC_QUERY
: sort === "birthYearDesc"
? COMPOSERS_BY_BIRTH_YEAR_DESC_QUERY
: COMPOSERS_BY_NAME_QUERY;
const [composers, total] = await Promise.all([
sanity.fetch<ComposerCardData[]>(listQuery, { start, end, q, qPattern }),
sanity.fetch<number>(COMPOSERS_COUNT_QUERY, { q, qPattern }),
]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const buildHref = (nextPage: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (sort !== "sortKey") params.set("sort", sort);
if (nextPage > 1) params.set("page", String(nextPage));
const qs = params.toString();
return qs ? `/composers?${qs}` : "/composers";
};
return (
<main className="mx-auto my-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
<div className="mb-10">
<AnimatedText text="Composers" as="h1" className="font-argesta text-3xl" />
<p className="mt-2 text-base text-lightsec dark:text-darksec">
{total ? `${total} composer(s)` : "No composers found."}
</p>
</div>
<div className="mb-12">
<SearchBar
initialQuery={q}
initialSort={sort}
initialPage={safePage}
defaultSort="sortKey"
placeholder="Search composers…"
sortOptions={SORT_OPTIONS}
sortAriaLabel="Sort composers"
sortMenuClassName="absolute right-0 z-20 mt-4 min-w-47 overflow-hidden rounded-2xl bg-lightbg shadow-lg ring-1 ring-lightline transition-colors duration-300 ease-in-out hover:ring-lightline-hover dark:bg-darkbg dark:ring-darkline dark:hover:ring-darkline-hover"
/>
</div>
<section className={CARD_GRID_CLASSES_4}>
{composers.map((composer) => (
<ArtistCard
key={composer._id}
name={composer.name}
subtitle={formatYears(composer.birthYear, composer.deathYear)}
image={composer.image}
href={composer.slug ? `/composer/${composer.slug}` : "/composers/"}
label="Composer"
/>
))}
</section>
{totalPages > 1 ? (
<PaginationNav page={safePage} totalPages={totalPages} buildHref={buildHref} />
) : null}
</main>
);
}

19
app/concerts/error.tsx Normal file
View file

@ -0,0 +1,19 @@
"use client";
export default function ConcertsError({ reset }: { error: Error; reset: () => void }) {
return (
<main className="mx-auto my-auto max-w-275 px-6 py-12 font-silka md:px-8 md:py-16">
<h1 className="mb-4 font-argesta text-3xl">Something went wrong</h1>
<p className="mb-6 text-lightsec dark:text-darksec">
We couldn&apos;t load the concerts. Please try again.
</p>
<button
type="button"
onClick={reset}
className="rounded-xl border border-lightline px-6 py-3 text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue dark:border-darkline dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white"
>
Try again
</button>
</main>
);
}

12
app/concerts/layout.tsx Normal file
View file

@ -0,0 +1,12 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

115
app/concerts/page.tsx Normal file
View file

@ -0,0 +1,115 @@
import type { Metadata } from "next";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { AnimatedText } from "@/components/AnimatedText";
import {
ConcertTable,
getDisplayTitle,
type ConcertData,
} from "@/components/concert/ConcertTable";
export const revalidate = 86400;
export const metadata: Metadata = {
title: "Concerts",
description:
"Upcoming and past TRPTK concerts. Find live performances by TRPTK artists near you.",
};
const CONCERT_PROJECTION = `{
_id,
title,
subtitle,
date,
time,
locationName,
city,
country,
"artists": artists[]->{ _id, name, "slug": slug.current },
ticketUrl
}`;
const UPCOMING_CONCERTS_QUERY = defineQuery(`
*[_type == "concert" && date >= $today]
${CONCERT_PROJECTION}
| order(date asc, time asc)
`);
const PAST_CONCERTS_QUERY = defineQuery(`
*[_type == "concert" && date < $today]
${CONCERT_PROJECTION}
| order(date desc, time desc)
`);
export default async function ConcertsPage() {
const today = new Date().toISOString().slice(0, 10);
const [upcoming, past] = await Promise.all([
sanity.fetch<ConcertData[]>(UPCOMING_CONCERTS_QUERY, { today }),
sanity.fetch<ConcertData[]>(PAST_CONCERTS_QUERY, { today }),
]);
const jsonLd = upcoming.length
? upcoming.map((concert) => ({
"@context": "https://schema.org",
"@type": "MusicEvent",
name: getDisplayTitle(concert),
startDate: `${concert.date}T${concert.time}:00`,
url: "https://trptk.com/concerts",
...(concert.locationName && {
location: {
"@type": "Place",
name: concert.locationName,
address: {
"@type": "PostalAddress",
...(concert.city && { addressLocality: concert.city }),
...(concert.country && {
addressCountry: concert.country.toUpperCase(),
}),
},
},
}),
...(concert.ticketUrl && {
offers: {
"@type": "Offer",
url: concert.ticketUrl,
},
}),
...(concert.artists?.length && {
performer: concert.artists.map((a) => ({
"@type": "MusicGroup",
name: a.name,
})),
}),
}))
: null;
return (
<>
{jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>
)}
<main className="mx-auto my-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
{upcoming.length > 0 && (
<section className="mb-16">
<AnimatedText text="Upcoming concerts" as="h2" className="mb-4 font-argesta text-3xl" />
<ConcertTable concerts={upcoming} />
</section>
)}
{past.length > 0 && (
<section>
<AnimatedText text="Past concerts" as="h2" className="mb-4 font-argesta text-3xl" />
<ConcertTable concerts={past} past />
</section>
)}
</main>
</>
);
}

BIN
app/favicon-16x16.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

BIN
app/favicon-32x32.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

BIN
app/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

106
app/globals.css Normal file
View file

@ -0,0 +1,106 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "./app/**/*.{js,ts,jsx,tsx,mdx}";
@source "./components/**/*.{js,ts,jsx,tsx,mdx}";
@source "./hooks/**/*.{js,ts,jsx,tsx,mdx}";
@theme {
--color-lightbg: oklch(0.945 0.006 252); /* Light Background */
--color-lighttext: oklch(0.2771 0.03774 261.549988); /* Light Text */
--color-lightsec: oklch(0.5564 0.04004 259.779999); /* Light Secondary */
--color-darkbg: oklch(0.30012 0.02171 262.519989); /* Dark Background */
--color-darktext: oklch(0.9355 0.0017 247.839996); /* Dark Text */
--color-darksec: oklch(0.7829 0.04004 259.779999); /* Dark Secondary */
--color-trptkblue: oklch(0.46077 0.13464 260.109985);
/* Solid border/line colours pre-blended current-on-bg */
--color-lightline: oklch(0.88 0.007 254.68); /* 5 % */
--color-lightline-mid: oklch(0.85 0.008 256.37); /* 10 % */
--color-lightline-strong: oklch(0.819 0.012 257.33); /* 20 % (lightsec on lightbg) */
--color-lightline-hover: oklch(0.759 0.013 259.04); /* 25 % */
--color-lightline-focus: oklch(0.601 0.02 260.8); /* 50 % */
--color-darkline: oklch(0.342 0.022 262.51); /* 5 % */
--color-darkline-mid: oklch(0.377 0.021 262.47); /* 10 % */
--color-darkline-strong: oklch(0.412 0.028 261.53); /* 20 % (darksec on darkbg) */
--color-darkline-hover: oklch(0.482 0.017 262.22); /* 25 % */
--color-darkline-focus: oklch(0.644 0.011 261.36); /* 50 % */
--font-argesta: var(--font-argesta-face), ui-serif, serif;
--font-silka: var(--font-silka-face), ui-sans-serif, sans-serif;
--font-silkasb: var(--font-silkasb-face), ui-sans-serif, sans-serif;
}
.break-words {
text-wrap: balance;
hyphens: auto;
}
.no-ring {
@apply focus:ring-0 focus:ring-offset-0 focus:outline-none;
}
.release-blur::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background-image: var(--cover-url);
background-size: cover;
background-position: center;
filter: blur(44px) saturate(1.5) brightness(1.2);
mix-blend-mode: normal;
opacity: 0;
transform: translateZ(0);
-webkit-transform: translateZ(0);
will-change: opacity, transform, filter;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.release-blur.loaded::before {
animation: releaseBlurFadeIn 2s ease-in-out forwards;
}
:root .release-blur::before {
mix-blend-mode: normal;
}
.dark .release-blur::before {
mix-blend-mode: screen;
}
@keyframes releaseBlurFadeIn {
from {
opacity: 0;
}
to {
opacity: 0.15;
}
}
.dark .release-blur.loaded::before {
animation: releaseBlurFadeInDark 2s ease-in-out forwards;
}
@keyframes releaseBlurFadeInDark {
from {
opacity: 0;
}
to {
opacity: 0.15;
}
}
@custom-variant dark (&:where(.dark, .dark *));
html,
body {
overflow-x: hidden;
max-width: 100%;
}

102
app/layout.tsx Normal file
View file

@ -0,0 +1,102 @@
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "next-themes";
import { AuthProvider } from "@/components/auth/AuthContext";
import { CartProvider } from "@/components/cart/CartContext";
import { CartDrawer } from "@/components/cart/CartDrawer";
import { PlayerProvider } from "@/components/player/PlayerContext";
import { Player } from "@/components/player/Player";
const argesta = localFont({
src: "../public/fonts/Argesta.woff2",
variable: "--font-argesta-face",
display: "swap",
});
const silka = localFont({
src: "../public/fonts/Silka-Regular.woff2",
variable: "--font-silka-face",
display: "swap",
});
const silkaSB = localFont({
src: "../public/fonts/Silka-SemiBold.woff2",
variable: "--font-silkasb-face",
display: "swap",
});
const isProduction =
process.env.NEXT_PUBLIC_APP_URL?.includes("trptk.com") &&
!process.env.NEXT_PUBLIC_APP_URL?.includes("staging");
export const metadata: Metadata = {
metadataBase: new URL("https://trptk.com"),
title: {
default: "TRPTK",
template: "%s • TRPTK",
},
description: "Recording the extraordinary.",
// Prevent search engines from indexing non-production deployments
...(!isProduction && {
robots: { index: false, follow: false },
}),
};
export const viewport: Viewport = {
maximumScale: 1,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#e8e9ea" },
{ media: "(prefers-color-scheme: dark)", color: "#282e39" },
],
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${argesta.variable} ${silka.variable} ${silkaSB.variable}`}
>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
name: "TRPTK",
url: "https://trptk.com",
description: "Recording the extraordinary.",
}).replace(/</g, "\\u003c"),
}}
/>
</head>
<body
className={`min-h-screen overflow-x-hidden bg-lightbg font-silka text-lighttext antialiased dark:bg-darkbg dark:text-darktext`}
>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={true}
disableTransitionOnChange={false}
storageKey="theme"
>
<AuthProvider>
<CartProvider>
<PlayerProvider>
{children}
<Player />
</PlayerProvider>
<CartDrawer />
</CartProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
}

440
app/page.tsx Normal file
View file

@ -0,0 +1,440 @@
import type { Metadata } from "next";
import { cache } from "react";
import Link from "next/link";
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { ReleaseCover } from "@/components/release/ReleaseCover";
import { urlFor } from "@/lib/sanityImage";
import { ARTIST_PLACEHOLDER_SRC, CARD_GRID_CLASSES_4 } from "@/lib/constants";
import { ArrowLink } from "@/components/ArrowLink";
import { IconButtonLink } from "@/components/IconButton";
import { IoPersonOutline } from "react-icons/io5";
import { ReleaseCard, type ReleaseCardData } from "@/components/release/ReleaseCard";
import { BlogCard, type BlogCardData } from "@/components/blog/BlogCard";
import { ConcertTable, type ConcertData } from "@/components/concert/ConcertTable";
import { formatYears } from "@/components/artist/types";
import type { SanityImageSource } from "@sanity/image-url";
import type { TrackData } from "@/components/release/Tracklist";
import { FeaturedReleaseActions } from "@/components/release/FeaturedReleaseActions";
export const revalidate = 86400;
type FeaturedAlbum = {
name?: string;
albumArtist?: string;
slug?: string;
albumCover?: SanityImageSource;
shortDescription?: string;
tracks?: TrackData[];
};
type FeaturedArtist = {
name?: string;
role?: string;
slug?: string;
image?: SanityImageSource;
bioExcerpt?: string;
};
type FeaturedComposer = {
name?: string;
slug?: string;
birthYear?: number;
deathYear?: number;
image?: SanityImageSource;
bioExcerpt?: string;
};
type HomeSettings = {
featuredAlbum: FeaturedAlbum | null;
featuredArtist: FeaturedArtist | null;
featuredComposer: FeaturedComposer | null;
};
const SETTINGS_QUERY = defineQuery(`
*[_type == "settings"][0]{
"featuredAlbum": featuredAlbum->{
name,
albumArtist,
"slug": slug.current,
albumCover,
shortDescription,
"tracks": tracks[]{
"workId": work->_id,
"workTitle": work->title,
"composerName": work->composer->name,
"arrangerName": work->arranger->name,
movement,
displayTitle,
duration,
artist,
"previewMp3Url": previewMp3.asset->url
}
},
"featuredArtist": featuredArtist->{
name,
role,
"slug": slug.current,
image,
"bioExcerpt": pt::text(bio)
},
"featuredComposer": featuredComposer->{
name,
"slug": slug.current,
birthYear,
deathYear,
image,
"bioExcerpt": pt::text(bio)
}
}
`);
const LATEST_RELEASES_QUERY = defineQuery(`
*[_type == "release"] | order(coalesce(releaseDate, "0000-01-01") desc) [0...8] {
_id,
name,
albumArtist,
catalogNo,
releaseDate,
"slug": slug.current,
albumCover
}
`);
const LATEST_BLOGS_QUERY = defineQuery(`
*[_type == "blog"] | order(coalesce(publishDate, "0000-01-01") desc) [0...8] {
_id,
title,
subtitle,
author,
publishDate,
category,
"slug": slug.current,
featuredImage
}
`);
const HOME_UPCOMING_CONCERTS_QUERY = defineQuery(`
*[_type == "concert" && date >= $today]
{
_id,
title,
subtitle,
date,
time,
locationName,
city,
country,
"artists": artists[]->{ _id, name, "slug": slug.current },
ticketUrl
}
| order(date asc, time asc) [0...10]
`);
const getHomeSettings = cache(async () => {
try {
return await sanity.fetch<HomeSettings>(SETTINGS_QUERY);
} catch (error) {
console.error("Failed to fetch home settings:", error);
return { featuredAlbum: null, featuredArtist: null, featuredComposer: null };
}
});
export async function generateMetadata(): Promise<Metadata> {
const { featuredAlbum: release } = await getHomeSettings();
const description = release?.shortDescription ?? "Recording the extraordinary.";
const ogImage = release?.albumCover
? urlFor(release.albumCover).width(1200).height(1200).url()
: undefined;
return {
title: "TRPTK • Recording the extraordinary",
description,
alternates: { canonical: "/" },
openGraph: {
title: "TRPTK",
description,
type: "website",
...(ogImage && {
images: [
{ url: ogImage, width: 1200, height: 1200, alt: "TRPTK • Recording the extraordinary" },
],
}),
},
twitter: {
card: "summary_large_image",
title: "TRPTK",
description,
...(ogImage && { images: [ogImage] }),
},
};
}
function cardVisibility(index: number) {
if (index < 4) return "";
if (index < 6) return "hidden md:block";
return "hidden lg:block";
}
export default async function Home() {
const today = new Date().toISOString().slice(0, 10);
const [
{ featuredAlbum: release, featuredArtist: artist, featuredComposer: composer },
latestReleases,
latestBlogs,
upcomingConcerts,
] = await Promise.all([
getHomeSettings(),
sanity.fetch<ReleaseCardData[]>(LATEST_RELEASES_QUERY),
sanity.fetch<BlogCardData[]>(LATEST_BLOGS_QUERY),
sanity.fetch<ConcertData[]>(HOME_UPCOMING_CONCERTS_QUERY, { today }),
]);
const displayName = release?.name ?? "";
const displayArtist = release?.albumArtist ?? "";
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebPage",
name: "TRPTK",
url: "https://trptk.com",
description: "Recording the extraordinary.",
...(release && {
mainEntity: {
"@type": "MusicAlbum",
name: release.name,
...(release.slug && { url: `https://trptk.com/release/${release.slug}` }),
...(release.albumArtist && {
byArtist: { "@type": "MusicGroup", name: release.albumArtist },
}),
...(release.albumCover && {
image: urlFor(release.albumCover).width(800).url(),
}),
},
}),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<Header />
<section className="mx-auto my-auto max-w-250 px-6 py-12 font-silka md:px-8 md:py-16">
{/* Featued Album */}
{release && (
<div className="mx-auto grid grid-cols-1 gap-10 sm:grid-cols-2 sm:gap-8 md:gap-12 lg:gap-20">
<div className="order-2 content-center sm:order-1">
<h3 className="mb-2 font-silka text-sm break-words text-lightsec dark:text-darksec">
Featured album
</h3>
{release.slug ? (
<Link href={`/release/${release.slug}`}>
<h1 className="mb-2 font-argesta text-3xl break-words">{displayName}</h1>
</Link>
) : (
<h1 className="mb-2 font-argesta text-3xl break-words">{displayName}</h1>
)}
<h2 className="mb-6 font-silka text-base break-words text-lightsec dark:text-darksec">
{displayArtist}
</h2>
{release.shortDescription && (
<p className="line-clamp-2 text-sm md:line-clamp-3 lg:line-clamp-4">
{release.shortDescription}
</p>
)}
{release.slug && (
<FeaturedReleaseActions
tracks={release.tracks ?? []}
albumCover={release.albumCover}
albumArtist={release.albumArtist}
releaseSlug={release.slug}
/>
)}
</div>
<div className="order-1 content-center sm:order-2">
{release.slug ? (
<Link href={`/release/${release.slug}`}>
<ReleaseCover
src={
release.albumCover ? urlFor(release.albumCover).url() : ARTIST_PLACEHOLDER_SRC
}
alt={`Album cover for ${displayName} by ${displayArtist}`}
/>
</Link>
) : (
<ReleaseCover
src={
release.albumCover ? urlFor(release.albumCover).url() : ARTIST_PLACEHOLDER_SRC
}
alt={`Album cover for ${displayName} by ${displayArtist}`}
/>
)}
</div>
</div>
)}
</section>
{/* Latest releases */}
{latestReleases.length > 0 && (
<section className="mx-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
<h2 className="font-argesta text-3xl">Latest releases</h2>
<ArrowLink
href="/releases"
className="mt-2 mb-8 inline-block text-sm text-lightsec dark:text-darksec"
>
Browse all releases
</ArrowLink>
<div className={CARD_GRID_CLASSES_4}>
{latestReleases.map((r, i) => (
<ReleaseCard key={r._id} release={r} className={cardVisibility(i)} />
))}
</div>
</section>
)}
{/* Featured Artist */}
{artist && (
<section className="mx-auto my-auto max-w-250 px-6 py-12 font-silka md:px-8 md:py-16">
<div className="mx-auto grid grid-cols-1 gap-10 sm:grid-cols-2 sm:gap-8 md:gap-12 lg:gap-20">
<div className="content-center">
{artist.slug ? (
<Link href={`/artist/${artist.slug}`}>
<ReleaseCover
src={artist.image ? urlFor(artist.image).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Photo of ${artist.name}`}
/>
</Link>
) : (
<ReleaseCover
src={artist.image ? urlFor(artist.image).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Photo of ${artist.name}`}
/>
)}
</div>
<div className="content-center">
<h3 className="mb-2 font-silka text-sm break-words text-lightsec dark:text-darksec">
Featured artist
</h3>
{artist.slug ? (
<Link href={`/artist/${artist.slug}`}>
<h2 className="mb-2 font-argesta text-3xl break-words">{artist.name}</h2>
</Link>
) : (
<h2 className="mb-2 font-argesta text-3xl break-words">{artist.name}</h2>
)}
<h3 className="mb-6 font-silka text-base break-words text-lightsec dark:text-darksec">
{artist.role}
</h3>
{artist.bioExcerpt && (
<p className="line-clamp-2 text-sm md:line-clamp-3 lg:line-clamp-4">
{artist.bioExcerpt}
</p>
)}
{artist.slug && (
<div className="mt-4 flex gap-3">
<IconButtonLink href={`/artist/${artist.slug}`} aria-label="View artist">
<IoPersonOutline />
</IconButtonLink>
</div>
)}
</div>
</div>
</section>
)}
{/* From the blog */}
{latestBlogs.length > 0 && (
<section className="mx-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
<h2 className="font-argesta text-3xl">From the blog</h2>
<ArrowLink
href="/blog"
className="mt-2 mb-8 inline-block text-sm text-lightsec dark:text-darksec"
>
Browse all posts
</ArrowLink>
<div className={CARD_GRID_CLASSES_4}>
{latestBlogs.map((b, i) => (
<BlogCard key={b._id} blog={b} className={cardVisibility(i)} />
))}
</div>
</section>
)}
{/* Featured Composer */}
{composer && (
<section className="mx-auto my-auto max-w-250 px-6 py-12 font-silka md:px-8 md:py-16">
<div className="mx-auto grid grid-cols-1 gap-10 sm:grid-cols-2 sm:gap-8 md:gap-12 lg:gap-20">
<div className="order-2 content-center sm:order-1">
<h3 className="mb-2 font-silka text-sm break-words text-lightsec dark:text-darksec">
Featured composer
</h3>
{composer.slug ? (
<Link href={`/composer/${composer.slug}`}>
<h2 className="mb-2 font-argesta text-3xl break-words">{composer.name}</h2>
</Link>
) : (
<h2 className="mb-2 font-argesta text-3xl break-words">{composer.name}</h2>
)}
<h3 className="mb-6 font-silka text-base break-words text-lightsec dark:text-darksec">
{formatYears(composer.birthYear, composer.deathYear)}
</h3>
{composer.bioExcerpt && (
<p className="line-clamp-2 text-sm md:line-clamp-3 lg:line-clamp-4">
{composer.bioExcerpt}
</p>
)}
{composer.slug && (
<div className="mt-4 flex gap-3">
<IconButtonLink href={`/composer/${composer.slug}`} aria-label="View composer">
<IoPersonOutline />
</IconButtonLink>
</div>
)}
</div>
<div className="order-1 content-center sm:order-2">
{composer.slug ? (
<Link href={`/composer/${composer.slug}`}>
<ReleaseCover
src={composer.image ? urlFor(composer.image).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Portrait of ${composer.name}`}
/>
</Link>
) : (
<ReleaseCover
src={composer.image ? urlFor(composer.image).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Portrait of ${composer.name}`}
/>
)}
</div>
</div>
</section>
)}
{/* Upcoming concerts */}
{upcomingConcerts.length > 0 && (
<section className="mx-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
<h2 className="font-argesta text-3xl">Upcoming concerts</h2>
<ArrowLink
href="/concerts"
className="mt-2 mb-8 inline-block text-sm text-lightsec dark:text-darksec"
>
Browse all concerts
</ArrowLink>
<ConcertTable concerts={upcomingConcerts} />
</section>
)}
<Footer />
</>
);
}

View file

@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { getAuthToken } from "@/lib/auth";
export const revalidate = 86400;
const BOOKLET_URL_QUERY = defineQuery(`
*[_type == "release" && slug.current == $slug][0]{
"bookletPdfUrl": bookletPdf.asset->url
}
`);
export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
const token = await getAuthToken();
if (!token) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const { slug } = await params;
if (!slug) return NextResponse.json({ error: "Not found" }, { status: 404 });
const release = await sanity.fetch<{ bookletPdfUrl?: string }>(BOOKLET_URL_QUERY, {
slug: slug.toLowerCase(),
});
if (!release?.bookletPdfUrl) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!release.bookletPdfUrl.startsWith("https://cdn.sanity.io/")) {
return NextResponse.json({ error: "Invalid PDF source" }, { status: 400 });
}
const pdfResponse = await fetch(release.bookletPdfUrl);
if (!pdfResponse.ok) {
return NextResponse.json({ error: "Failed to fetch PDF" }, { status: 502 });
}
const pdfBuffer = await pdfResponse.arrayBuffer();
return new NextResponse(pdfBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": "inline",
"X-Robots-Tag": "noindex, nofollow",
"Cache-Control": "public, max-age=86400, s-maxage=86400",
},
});
}

View file

@ -0,0 +1,11 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

502
app/release/[slug]/page.tsx Normal file
View file

@ -0,0 +1,502 @@
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<ReleaseDetail>(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<Array<{ slug: string }>>(RELEASE_SLUGS_QUERY);
return slugs.map((r) => ({ slug: r.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
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<T extends { _id: string }>(items: (T | null)[]): T[] {
const seen = new Set<string>();
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<ReturnType<typeof matchVariants>> = [];
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<string, string> = {
album: "AlbumRelease",
ep: "EPRelease",
single: "SingleRelease",
};
const genreLabelMap: Record<string, string> = {
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 (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<main className="mx-auto my-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
{/* Hero */}
<div className="mx-auto grid grid-cols-1 gap-10 sm:grid-cols-2 sm:gap-8 md:gap-12 lg:gap-20">
<div className="flex-1 content-center">
<ReleaseCover
src={release.albumCover ? urlFor(release.albumCover).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Album cover for ${displayName} by ${displayArtist}`}
/>
</div>
<div className="flex-1 content-center">
<Breadcrumb crumbs={[{ label: "Releases", href: "/releases" }]} />
<AnimatedText
text={displayName}
as="h1"
className="mb-2 font-argesta text-3xl break-words"
/>
<AnimatedText
text={displayArtist}
as="h2"
className="font-silka text-base break-words text-lightsec dark:text-darksec"
delay={0.25}
/>
{formatGroups.length > 0 && (
<FormatSelector groups={formatGroups} releaseDate={release.releaseDate} />
)}
</div>
</div>
{/* Description & Tracklist */}
{(release.description || true) && (
<section className="mt-16 grid grid-cols-1 gap-10 sm:mt-20 sm:gap-12 md:gap-16 lg:grid-cols-2 lg:gap-20">
<div>
<AnimatedText text="About the album" as="h2" className="mb-4 font-argesta text-2xl" />
{release.description ? (
<article className="prose max-w-none text-lighttext dark:text-darktext dark:prose-invert prose-h2:!mt-[3em] prose-h2:font-silkasb prose-h2:text-base prose-strong:font-silkasb">
<PortableText value={release.description} components={portableTextComponents} />
</article>
) : (
<p className="text-lightsec dark:text-darksec">No description available yet.</p>
)}
</div>
<div>
<AnimatedText text="Tracklist" as="h2" className="mb-4 font-argesta text-2xl" />
<Tracklist
tracks={release.tracks ?? []}
albumCover={release.albumCover}
albumArtist={displayArtist}
releaseSlug={slug}
/>
</div>
</section>
)}
<section className="mt-16 grid grid-cols-1 gap-16 sm:mt-20 md:grid-cols-2 md:gap-12 lg:gap-20">
{/* Artists */}
{artists.length > 0 && (
<div className="">
<AnimatedText text="Artists" as="h2" className="mb-4 font-argesta text-2xl" />
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
{artists.map((artist) => (
<ArtistCardCompact
key={artist._id}
name={artist.name}
subtitle={artist.role}
image={artist.image}
href={artist.slug ? `/artist/${artist.slug}` : "/artists/"}
/>
))}
</ul>
</div>
)}
{/* Composers */}
{composers.length > 0 && (
<div className="">
<AnimatedText text="Composers" as="h2" className="mb-4 font-argesta text-2xl" />
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
{composers.map((composer) => (
<ArtistCardCompact
key={composer._id}
name={composer.name}
subtitle={formatYears(composer.birthYear, composer.deathYear)}
image={composer.image}
href={composer.slug ? `/composer/${composer.slug}` : "/composers/"}
label="Composer"
/>
))}
</ul>
</div>
)}
</section>
{/* Reviews */}
{release.reviews && release.reviews.length > 0 && (
<section className="mx-auto my-32 max-w-150 sm:my-40">
<div className="grid grid-cols-1 gap-12">
{release.reviews.map((review, i) => (
<blockquote key={i} className="rounded-xl text-center">
{review.quote && (
<p className="leading-relaxed text-lighttext dark:text-darktext">
&ldquo;{review.quote}&rdquo;
</p>
)}
{review.author && (
<footer className="mt-2 text-sm text-lightsec dark:text-darksec">
{review.author}
</footer>
)}
</blockquote>
))}
</div>
</section>
)}
{/* Credits & Equipment */}
{(hasCreditsInfo || hasEquipment) && (
<section className="mt-16 grid grid-cols-1 gap-16 sm:mt-20 md:grid-cols-2 md:gap-12 lg:gap-20">
{/* Credits */}
{hasCreditsInfo && (
<div>
<AnimatedText text="Credits" as="h2" className="mb-4 font-argesta text-2xl" />
<DetailTable
rows={[
...(release.credits?.map((c) => ({ label: c.role ?? "", value: c.name })) ??
[]),
{ label: "Recording date", value: release.recordingDate },
{ label: "Recording location", value: release.recordingLocation },
{ label: "Recording format", value: release.recordingFormat },
{ label: "Mastering format", value: release.masteringFormat },
{ label: "Release date", value: displayDate ?? undefined },
{
label: "Booklet",
value: release.bookletPdfUrl ? (
<BookletLink slug={release.slug!} />
) : undefined,
},
]}
/>
</div>
)}
{/* Equipment */}
{hasEquipment && (
<div>
<AnimatedText
text="Technical specifications"
as="h2"
className="mb-4 font-argesta text-2xl"
/>
<DetailTable
rows={release.equipment!.map((item) => ({
label: item.type ?? "",
value: item.name,
}))}
/>
</div>
)}
</section>
)}
</main>
</>
);
}

19
app/releases/error.tsx Normal file
View file

@ -0,0 +1,19 @@
"use client";
export default function ReleasesError({ reset }: { error: Error; reset: () => void }) {
return (
<main className="mx-auto my-auto max-w-275 px-6 py-12 font-silka md:px-8 md:py-16">
<h1 className="mb-4 font-argesta text-3xl">Something went wrong</h1>
<p className="mb-6 text-lightsec dark:text-darksec">
We couldn&apos;t load the releases. Please try again.
</p>
<button
type="button"
onClick={reset}
className="rounded-xl border border-lightline px-6 py-3 text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue dark:border-darkline dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white"
>
Try again
</button>
</main>
);
}

12
app/releases/layout.tsx Normal file
View file

@ -0,0 +1,12 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

259
app/releases/page.tsx Normal file
View file

@ -0,0 +1,259 @@
import type { Metadata } from "next";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { ReleaseCard, type ReleaseCardData } from "@/components/release/ReleaseCard";
import { AnimatedText } from "@/components/AnimatedText";
import { SearchBar } from "@/components/SearchBar";
import { PaginationNav } from "@/components/PaginationNav";
import { CARD_GRID_CLASSES_4 } from "@/lib/constants";
import { PAGE_SIZE, clampInt, normalizeQuery, groqLikeParam } from "@/lib/listHelpers";
import type { SortOption } from "@/components/SortDropdown";
import type { FilterGroup } from "@/components/FilterDropdown";
export const revalidate = 86400;
type SortMode =
| "titleAsc"
| "titleDesc"
| "catalogNoAsc"
| "catalogNoDesc"
| "releaseDateAsc"
| "releaseDateDesc";
const GENRE_OPTIONS = [
{ value: "earlyMusic", label: "Early Music" },
{ value: "baroque", label: "Baroque" },
{ value: "classical", label: "Classical" },
{ value: "romantic", label: "Romantic" },
{ value: "contemporary", label: "Contemporary" },
{ value: "worldMusic", label: "World Music" },
{ value: "jazz", label: "Jazz" },
{ value: "crossover", label: "Crossover" },
{ value: "electronic", label: "Electronic" },
{ value: "minimal", label: "Minimal" },
{ value: "popRock", label: "Pop / Rock" },
];
const INSTRUMENTATION_OPTIONS = [
{ value: "solo", label: "Solo" },
{ value: "chamber", label: "Chamber" },
{ value: "ensemble", label: "Ensemble" },
{ value: "orchestra", label: "Orchestral" },
{ value: "vocalChoral", label: "Vocal / Choral" },
];
const FILTER_GROUPS: FilterGroup[] = [
{ label: "Genre", param: "genre", options: GENRE_OPTIONS },
{ label: "Instrumentation", param: "instrumentation", options: INSTRUMENTATION_OPTIONS },
];
const VALID_GENRES = new Set(GENRE_OPTIONS.map((o) => o.value));
const VALID_INSTRUMENTATIONS = new Set(INSTRUMENTATION_OPTIONS.map((o) => o.value));
function parseFilterParam(raw: string | undefined, valid: Set<string>): string[] {
if (!raw) return [];
return raw.split(",").filter((v) => valid.has(v));
}
const SORT_OPTIONS: SortOption<SortMode>[] = [
{ value: "titleAsc", label: "Title", iconDirection: "asc" },
{ value: "titleDesc", label: "Title", iconDirection: "desc" },
{ value: "catalogNoAsc", label: "Cat. No.", iconDirection: "asc" },
{ value: "catalogNoDesc", label: "Cat. No.", iconDirection: "desc" },
{ value: "releaseDateAsc", label: "Release date", iconDirection: "asc" },
{ value: "releaseDateDesc", label: "Release date", iconDirection: "desc" },
];
function normalizeSort(s: string | undefined): SortMode {
switch (s) {
case "titleAsc":
case "titleDesc":
case "catalogNoAsc":
case "catalogNoDesc":
case "releaseDateAsc":
case "releaseDateDesc":
return s;
default:
return "releaseDateDesc";
}
}
const RELEASE_FILTER_CLAUSE = `
_type == "release" &&
(
$q == "" ||
name match $qPattern ||
albumArtist match $qPattern ||
catalogNo match $qPattern
) &&
(count($genres) == 0 || count(genre[@ in $genres]) > 0) &&
(count($instrumentations) == 0 || count(instrumentation[@ in $instrumentations]) > 0)
`;
const RELEASE_PROJECTION = `{
_id,
name,
albumArtist,
catalogNo,
releaseDate,
"slug": slug.current,
albumCover
}`;
const RELEASES_BY_TITLE_ASC_QUERY = defineQuery(`
*[
${RELEASE_FILTER_CLAUSE}
]
| order(lower(name) asc)
[$start...$end]${RELEASE_PROJECTION}
`);
const RELEASES_BY_TITLE_DESC_QUERY = defineQuery(`
*[
${RELEASE_FILTER_CLAUSE}
]
| order(lower(name) desc)
[$start...$end]${RELEASE_PROJECTION}
`);
const RELEASES_BY_CATALOG_NO_ASC_QUERY = defineQuery(`
*[
${RELEASE_FILTER_CLAUSE}
]
| order(lower(catalogNo) asc, lower(name) asc)
[$start...$end]${RELEASE_PROJECTION}
`);
const RELEASES_BY_CATALOG_NO_DESC_QUERY = defineQuery(`
*[
${RELEASE_FILTER_CLAUSE}
]
| order(lower(catalogNo) desc, lower(name) asc)
[$start...$end]${RELEASE_PROJECTION}
`);
const RELEASES_BY_DATE_ASC_QUERY = defineQuery(`
*[
${RELEASE_FILTER_CLAUSE}
]
| order(coalesce(releaseDate, "9999-12-31") asc, lower(name) asc)
[$start...$end]${RELEASE_PROJECTION}
`);
const RELEASES_BY_DATE_DESC_QUERY = defineQuery(`
*[
${RELEASE_FILTER_CLAUSE}
]
| order(coalesce(releaseDate, "0000-01-01") desc, lower(name) asc)
[$start...$end]${RELEASE_PROJECTION}
`);
const RELEASES_COUNT_QUERY = defineQuery(`
count(*[
${RELEASE_FILTER_CLAUSE}
])
`);
export const metadata: Metadata = {
title: "Releases",
description:
"Explore the full TRPTK catalogue. Filter by genre and instrumentation to find your next favourite recording.",
};
export default async function ReleasesPage({
searchParams,
}: {
searchParams: Promise<{
page?: string;
q?: string;
sort?: string;
genre?: string;
instrumentation?: string;
}>;
}) {
const sp = await searchParams;
const q = normalizeQuery(sp.q);
const sort = normalizeSort(sp.sort);
const page = clampInt(sp.page, 1, 1, 9999);
const genres = parseFilterParam(sp.genre, VALID_GENRES);
const instrumentations = parseFilterParam(sp.instrumentation, VALID_INSTRUMENTATIONS);
const start = (page - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
const qPattern = q ? `${groqLikeParam(q)}*` : "";
const listQuery =
sort === "titleDesc"
? RELEASES_BY_TITLE_DESC_QUERY
: sort === "catalogNoAsc"
? RELEASES_BY_CATALOG_NO_ASC_QUERY
: sort === "catalogNoDesc"
? RELEASES_BY_CATALOG_NO_DESC_QUERY
: sort === "releaseDateAsc"
? RELEASES_BY_DATE_ASC_QUERY
: sort === "releaseDateDesc"
? RELEASES_BY_DATE_DESC_QUERY
: RELEASES_BY_TITLE_ASC_QUERY;
const queryParams = { start, end, q, qPattern, genres, instrumentations };
const [releases, total] = await Promise.all([
sanity.fetch<ReleaseCardData[]>(listQuery, queryParams),
sanity.fetch<number>(RELEASES_COUNT_QUERY, queryParams),
]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const initialFilters: Record<string, string[]> = {};
if (genres.length) initialFilters.genre = genres;
if (instrumentations.length) initialFilters.instrumentation = instrumentations;
const buildHref = (nextPage: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (sort !== "releaseDateDesc") params.set("sort", sort);
if (genres.length) params.set("genre", genres.join(","));
if (instrumentations.length) params.set("instrumentation", instrumentations.join(","));
if (nextPage > 1) params.set("page", String(nextPage));
const qs = params.toString();
return qs ? `/releases?${qs}` : "/releases";
};
return (
<main className="mx-auto my-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
<div className="mb-10">
<AnimatedText text="Releases" as="h1" className="font-argesta text-3xl" />
<p className="mt-2 text-base text-lightsec dark:text-darksec">
{total ? `${total} release(s)` : "No releases found."}
</p>
</div>
<div className="mb-12">
<SearchBar
initialQuery={q}
initialSort={sort}
initialPage={safePage}
defaultSort="releaseDateDesc"
placeholder="Search releases…"
sortOptions={SORT_OPTIONS}
sortAriaLabel="Sort releases"
filterGroups={FILTER_GROUPS}
initialFilters={initialFilters}
/>
</div>
<section className={CARD_GRID_CLASSES_4}>
{releases.map((release) => (
<ReleaseCard key={release._id} release={release} />
))}
</section>
{totalPages > 1 ? (
<PaginationNav page={safePage} totalPages={totalPages} buildHref={buildHref} />
) : null}
</main>
);
}

25
app/robots.ts Normal file
View file

@ -0,0 +1,25 @@
import type { MetadataRoute } from "next";
const isProduction =
process.env.NEXT_PUBLIC_APP_URL?.includes("trptk.com") &&
!process.env.NEXT_PUBLIC_APP_URL?.includes("staging");
export default function robots(): MetadataRoute.Robots {
// Block all crawlers on non-production environments (e.g. staging.trptk.com)
if (!isProduction) {
return {
rules: [{ userAgent: "*", disallow: "/" }],
};
}
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/account/", "/checkout/", "/api/"],
},
],
sitemap: "https://trptk.com/sitemap.xml",
};
}

1
app/site.webmanifest Executable file
View file

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

68
app/sitemap.ts Normal file
View file

@ -0,0 +1,68 @@
import type { MetadataRoute } from "next";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
const SITEMAP_ARTISTS_QUERY = defineQuery(
`*[_type == "artist" && defined(slug.current)]{ "slug": slug.current, _updatedAt }`,
);
const SITEMAP_RELEASES_QUERY = defineQuery(
`*[_type == "release" && defined(slug.current)]{ "slug": slug.current, _updatedAt }`,
);
const SITEMAP_COMPOSERS_QUERY = defineQuery(
`*[_type == "composer" && defined(slug.current)]{ "slug": slug.current, _updatedAt }`,
);
const SITEMAP_WORKS_QUERY = defineQuery(
`*[_type == "work" && defined(slug.current)]{ "slug": slug.current, _updatedAt }`,
);
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "https://trptk.com";
const [artists, releases, composers, works] = await Promise.all([
sanity.fetch<{ slug: string; _updatedAt: string }[]>(SITEMAP_ARTISTS_QUERY),
sanity.fetch<{ slug: string; _updatedAt: string }[]>(SITEMAP_RELEASES_QUERY),
sanity.fetch<{ slug: string; _updatedAt: string }[]>(SITEMAP_COMPOSERS_QUERY),
sanity.fetch<{ slug: string; _updatedAt: string }[]>(SITEMAP_WORKS_QUERY),
]);
const staticPages: MetadataRoute.Sitemap = [
{ url: baseUrl, changeFrequency: "weekly", priority: 1 },
{ url: `${baseUrl}/artists`, changeFrequency: "weekly", priority: 0.8 },
{ url: `${baseUrl}/releases`, changeFrequency: "weekly", priority: 0.8 },
{ url: `${baseUrl}/composers`, changeFrequency: "weekly", priority: 0.8 },
{ url: `${baseUrl}/concerts`, changeFrequency: "weekly", priority: 0.8 },
];
const artistPages: MetadataRoute.Sitemap = artists.map((a) => ({
url: `${baseUrl}/artist/${a.slug}`,
lastModified: a._updatedAt,
changeFrequency: "monthly",
priority: 0.7,
}));
const releasePages: MetadataRoute.Sitemap = releases.map((r) => ({
url: `${baseUrl}/release/${r.slug}`,
lastModified: r._updatedAt,
changeFrequency: "monthly",
priority: 0.7,
}));
const composerPages: MetadataRoute.Sitemap = composers.map((c) => ({
url: `${baseUrl}/composer/${c.slug}`,
lastModified: c._updatedAt,
changeFrequency: "monthly",
priority: 0.6,
}));
const workPages: MetadataRoute.Sitemap = works.map((w) => ({
url: `${baseUrl}/work/${w.slug}`,
lastModified: w._updatedAt,
changeFrequency: "monthly",
priority: 0.5,
}));
return [...staticPages, ...artistPages, ...releasePages, ...composerPages, ...workPages];
}

View file

@ -0,0 +1,12 @@
import { Header } from "@/components/header/Header";
import { Footer } from "@/components/footer/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}

217
app/work/[slug]/page.tsx Normal file
View file

@ -0,0 +1,217 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { cache } from "react";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { ArtistReleasesTab, type ArtistRelease } from "@/components/artist/ArtistReleasesTab";
import { ReleaseCover } from "@/components/release/ReleaseCover";
import { AnimatedText } from "@/components/AnimatedText";
import { TabsClient, type TabDef } from "@/components/TabsClient";
import { PortableText } from "@portabletext/react";
import { urlFor } from "@/lib/sanityImage";
import { portableTextComponents } from "@/lib/portableTextComponents";
import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants";
import { Breadcrumb } from "@/components/Breadcrumb";
export const dynamicParams = true;
export const revalidate = 86400;
type Work = {
title?: string;
slug?: string;
description?: any;
composerName?: string;
composerSlug?: string;
composerImage?: any;
arrangerName?: string;
releases?: ArtistRelease[];
};
const WORK_DETAIL_QUERY = defineQuery(`
*[_type == "work" && slug.current == $slug][0]{
title,
description,
"slug": slug.current,
"composerName": composer->name,
"composerSlug": composer->slug.current,
"composerImage": composer->image,
"arrangerName": arranger->name,
"releases": *[
_type == "release" &&
count(tracks[work->slug.current == $slug]) > 0
]
| order(releaseDate desc, catalogNo desc) {
_id,
name,
albumArtist,
catalogNo,
"slug": slug.current,
releaseDate,
albumCover,
genre,
instrumentation
}
}
`);
const getWork = cache(async (slug: string) => {
try {
return await sanity.fetch<Work>(WORK_DETAIL_QUERY, { slug });
} catch (error) {
console.error("Failed to fetch work:", error);
return null;
}
});
const WORK_SLUGS_QUERY = defineQuery(
`*[_type == "work" && defined(slug.current)]{ "slug": slug.current }`,
);
export async function generateStaticParams() {
const slugs = await sanity.fetch<Array<{ slug: string }>>(WORK_SLUGS_QUERY);
return slugs.map((w) => ({ slug: w.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug: rawSlug } = await params;
const slug = rawSlug.toLowerCase();
const work = await getWork(slug);
if (!work) notFound();
const subtitle = work.arrangerName
? `${work.composerName} (arr. ${work.arrangerName})`
: (work.composerName ?? "");
const description = `Explore releases featuring ${work.title} by ${subtitle} on TRPTK.`;
const ogImage = work.composerImage
? urlFor(work.composerImage).width(1200).height(630).url()
: undefined;
return {
title: `${work.title} ${subtitle ? `${subtitle}` : ""}`,
description,
alternates: { canonical: `/work/${slug}` },
openGraph: {
title: work.title,
description: `${subtitle}${work.releases?.length ? ` - ${work.releases.length} release(s)` : ""}`,
type: "music.album",
...(ogImage && { images: [{ url: ogImage, width: 1200, height: 630, alt: `${work.title} by ${subtitle}` }] }),
},
twitter: {
card: "summary_large_image",
title: work.title,
description,
...(ogImage && { images: [ogImage] }),
},
};
}
export default async function WorkPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!slug) notFound();
const normalizedSlug = slug.toLowerCase();
if (slug !== normalizedSlug) {
redirect(`/work/${normalizedSlug}`);
}
const work = await getWork(slug);
if (!work) notFound();
const displayTitle = work.title ?? "";
const displaySubtitle = work.arrangerName
? `${work.composerName} (arr. ${work.arrangerName})`
: (work.composerName ?? "");
const jsonLd = {
"@context": "https://schema.org",
"@type": "MusicComposition",
name: work.title,
url: `https://trptk.com/work/${slug}`,
...(work.composerName && {
composer: { "@type": "Person", name: work.composerName },
}),
...(work.composerImage && {
image: urlFor(work.composerImage).width(800).url(),
}),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<main className="mx-auto my-auto max-w-250 px-6 py-12 font-silka md:px-8 md:py-16">
<div className="mx-auto grid grid-cols-1 gap-10 sm:grid-cols-2 sm:gap-8 md:gap-12 lg:gap-20">
<div className="flex-1 content-center">
<ReleaseCover
src={work.composerImage ? urlFor(work.composerImage).url() : ARTIST_PLACEHOLDER_SRC}
alt={`Photo of ${work.composerName}`}
/>
</div>
<div className="flex-1 content-center">
<Breadcrumb crumbs={[
{ label: "Composers", href: "/composers" },
...(work.composerName && work.composerSlug
? [{ label: work.composerName, href: `/composer/${work.composerSlug}` }]
: []),
]} />
<AnimatedText
text={displayTitle}
as="h1"
className="mb-2 font-argesta text-3xl break-words"
/>
<AnimatedText
text={displaySubtitle}
as="h2"
className="font-silka text-base break-words text-lightsec dark:text-darksec"
delay={0.25}
/>
</div>
</div>
<WorkTabs description={work.description} releases={work.releases ?? []} />
</main>
</>
);
}
function WorkTabs({ description, releases }: { description?: any; releases: ArtistRelease[] }) {
const tabs: TabDef[] = [
{
id: "description",
label: "Description",
content: (
<article className="prose max-w-none text-lighttext dark:text-darktext dark:prose-invert prose-h2:!mt-[3em] prose-h2:font-silkasb prose-h2:text-base">
<h2 className="sr-only">Description</h2>
{description ? (
<PortableText value={description} components={portableTextComponents} />
) : (
<p>No description available for this work yet.</p>
)}
</article>
),
},
];
if (releases.length > 0) {
tabs.push({
id: "releases",
label: "Releases",
content: <ArtistReleasesTab releases={releases} />,
});
}
return <TabsClient defaultTabId="description" tabs={tabs} />;
}

View file

@ -0,0 +1,43 @@
"use client";
import { motion } from "framer-motion";
type TextElement = "h1" | "h2" | "h3" | "p" | "span";
const MOTION_TAGS = {
h1: motion.h1,
h2: motion.h2,
h3: motion.h3,
p: motion.p,
span: motion.span,
} as const;
type AnimatedTextProps = {
text: string;
as?: TextElement;
className?: string;
duration?: number;
delay?: number;
};
export function AnimatedText({
text,
as = "h2",
className,
duration = 0.5,
delay = 0,
}: AnimatedTextProps) {
const MotionTag = MOTION_TAGS[as];
return (
<MotionTag
className={className}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "tween", ease: "easeInOut", duration, delay }}
style={{ display: "block", willChange: "transform, opacity" }}
>
{text}
</MotionTag>
);
}

46
components/ArrowLink.tsx Normal file
View file

@ -0,0 +1,46 @@
import Link from "next/link";
import { ReactNode } from "react";
const arrowChildren = (children: ReactNode) => (
<>
<span className="transition-opacity duration-300 ease-in-out group-hover:opacity-67">
{children}
</span>
<span
className="translate-x-0 opacity-0 transition-all duration-300 ease-in-out group-hover:translate-x-1 group-hover:opacity-33"
aria-hidden
>
</span>
</>
);
type ArrowLinkProps = {
href: string;
children: ReactNode;
className?: string;
target?: string;
rel?: string;
};
export function ArrowLink({ href, children, className, target, rel }: ArrowLinkProps) {
return (
<Link href={href} className={["group inline-flex items-baseline gap-1", className].join(" ")} target={target} rel={rel}>
{arrowChildren(children)}
</Link>
);
}
type ArrowButtonProps = {
onClick: () => void;
children: ReactNode;
className?: string;
};
export function ArrowButton({ onClick, children, className }: ArrowButtonProps) {
return (
<button type="button" onClick={onClick} className={["group inline-flex items-baseline gap-1", className].join(" ")}>
{arrowChildren(children)}
</button>
);
}

43
components/Breadcrumb.tsx Normal file
View file

@ -0,0 +1,43 @@
import Link from "next/link";
import { IoChevronForward } from "react-icons/io5";
const BASE_URL = "https://trptk.com";
type Crumb = { label: string; href: string };
export function Breadcrumb({ crumbs }: { crumbs: Crumb[] }) {
if (crumbs.length === 0) return null;
const jsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: crumbs.map((crumb, i) => ({
"@type": "ListItem",
position: i + 1,
name: crumb.label,
item: `${BASE_URL}${crumb.href}`,
})),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<nav className="mb-2 flex items-center gap-1 text-xs text-lightsec dark:text-darksec">
{crumbs.map((crumb, i) => (
<span key={crumb.href} className="flex items-center gap-1">
{i > 0 && <IoChevronForward className="opacity-50" />}
<Link
href={crumb.href}
className="transition-colors duration-200 hover:text-trptkblue dark:hover:text-white"
>
{crumb.label}
</Link>
</span>
))}
</nav>
</>
);
}

View file

@ -0,0 +1,116 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { COUNTRIES } from "@/lib/countries";
interface CountrySelectProps {
value: string;
onChange: (code: string) => void;
className?: string;
}
export function CountrySelect({ value, onChange, className }: CountrySelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selected = COUNTRIES.find((c) => c.code === value);
const filtered = search
? COUNTRIES.filter((c) =>
c.label.toLowerCase().includes(search.toLowerCase()),
)
: COUNTRIES;
// Close on outside click
useEffect(() => {
function handle(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, []);
// Focus search input when opened
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
return (
<div ref={ref} className="relative">
{/* Trigger button */}
<button
type="button"
onClick={() => {
setOpen((o) => !o);
setSearch("");
}}
className={`${className} flex items-center justify-between gap-2 text-left`}
>
<span className={selected ? "" : "text-lightsec dark:text-darksec"}>
{selected?.label ?? "Select country"}
</span>
<svg
className="h-4 w-4 shrink-0 text-lightsec dark:text-darksec"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
</button>
{/* Dropdown */}
{open && (
<div className="absolute left-0 z-50 mt-1 w-full overflow-hidden rounded-xl border border-lightline bg-white shadow-lg dark:border-darkline dark:bg-darkbg">
{/* Search */}
<div className="border-b border-lightline p-2 dark:border-darkline">
<input
ref={inputRef}
type="text"
placeholder="Search countries…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-lg bg-transparent px-3 py-2 text-sm outline-none placeholder:text-lightsec dark:placeholder:text-darksec"
/>
</div>
{/* Options */}
<ul className="max-h-48 overflow-y-auto">
{filtered.length === 0 && (
<li className="px-4 py-3 text-sm text-lightsec dark:text-darksec">
No results
</li>
)}
{filtered.map((c) => (
<li key={c.code}>
<button
type="button"
onClick={() => {
onChange(c.code);
setOpen(false);
setSearch("");
}}
className={`w-full px-4 py-2.5 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-white/5 ${
c.code === value
? "font-silkasb text-trptkblue dark:text-white"
: ""
}`}
>
{c.label}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,142 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { IoFilterOutline } from "react-icons/io5";
import { useClickOutside } from "@/hooks/useClickOutside";
import { IconButton } from "@/components/IconButton";
export type FilterGroup = {
label: string;
param: string;
options: { value: string; label: string }[];
};
type Props = {
groups: FilterGroup[];
values: Record<string, string[]>;
onChange: (param: string, selected: string[]) => void;
menuClassName?: string;
};
export function FilterDropdown({ groups, values, onChange, menuClassName }: Props) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuId = useId();
const activeCount = Object.values(values).reduce((sum, arr) => sum + arr.length, 0);
useClickOutside([buttonRef, menuRef], () => setOpen(false), open);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open]);
const toggle = (param: string, value: string) => {
const current = values[param] ?? [];
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
onChange(param, next);
};
return (
<div className="relative">
<IconButton
ref={buttonRef}
onClick={() => setOpen((v) => !v)}
className="text-lg"
aria-haspopup="true"
aria-expanded={open}
aria-controls={menuId}
aria-label="Filter releases"
>
<span className="relative">
<IoFilterOutline />
{activeCount > 0 ? (
<span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-trptkblue text-xs leading-none font-bold text-white dark:bg-white dark:text-lighttext">
{activeCount}
</span>
) : null}
</span>
</IconButton>
<AnimatePresence>
{open ? (
<motion.div
ref={menuRef}
id={menuId}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ type: "tween", ease: "easeInOut", duration: 0.25 }}
className={
menuClassName ??
"absolute right-0 z-20 mt-4 min-w-52 overflow-hidden rounded-2xl bg-lightbg p-4 shadow-lg ring-1 ring-lightline transition-[box-shadow,color,background-color] duration-300 ease-in-out hover:ring-lightline-hover dark:bg-darkbg dark:ring-darkline dark:hover:ring-darkline-hover"
}
>
{groups.map((group, gi) => (
<div key={group.param}>
{gi > 0 ? <div className="mt-4 border-t border-lightline-mid pt-4 dark:border-darkline-mid" /> : null}
<p className="mb-1 text-lightsec dark:text-darksec">{group.label}</p>
<div className="flex flex-col gap-2">
{group.options.map((opt) => {
const checked = (values[group.param] ?? []).includes(opt.value);
return (
<button
key={opt.value}
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => toggle(group.param, opt.value)}
className={[
"flex w-full items-center gap-3 text-left text-sm",
"transition-colors duration-200 ease-in-out",
checked ? "font-medium text-current" : "text-lightsec dark:text-darksec",
"hover:text-trptkblue dark:hover:text-white",
].join(" ")}
>
<span
className={[
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors duration-200",
checked
? "border-trptkblue bg-trptkblue text-darktext dark:border-white dark:bg-white dark:text-lighttext"
: "border-lightline-strong dark:border-darkline-strong",
].join(" ")}
>
{checked ? (
<svg
viewBox="0 0 12 12"
className="h-2.5 w-2.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 6l3 3 5-5" />
</svg>
) : null}
</span>
<span>{opt.label}</span>
</button>
);
})}
</div>
</div>
))}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}

47
components/IconButton.tsx Normal file
View file

@ -0,0 +1,47 @@
"use client";
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from "react";
import Link, { type LinkProps } from "next/link";
const baseClasses = [
"relative z-10 rounded-xl border border-lightline bg-lightbg p-4 shadow-lg dark:border-darkline dark:bg-darkbg",
"text-lighttext dark:text-darktext text-lg",
"hover:border-lightline-hover hover:text-trptkblue dark:hover:border-darkline-hover dark:hover:text-white",
"transition-all duration-200 ease-in-out",
"disabled:pointer-events-none disabled:opacity-50",
];
export const iconButtonClass = baseClasses.join(" ");
type IconButtonProps = ComponentPropsWithoutRef<"button"> & {
children: ReactNode;
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ children, className, ...props },
ref,
) {
return (
<button
ref={ref}
type="button"
className={[...baseClasses, className].filter(Boolean).join(" ")}
{...props}
>
{children}
</button>
);
});
type IconButtonLinkProps = Omit<ComponentPropsWithoutRef<"a">, keyof LinkProps> &
LinkProps & {
children: ReactNode;
};
export function IconButtonLink({ children, className, ...props }: IconButtonLinkProps) {
return (
<Link className={[...baseClasses, className].filter(Boolean).join(" ")} {...props}>
{children}
</Link>
);
}

View file

@ -0,0 +1,46 @@
"use client";
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from "react";
import Link, { type LinkProps } from "next/link";
const miniClasses = [
"relative z-10 rounded-lg border border-lightline bg-lightbg p-2 shadow-md dark:border-darkline dark:bg-darkbg",
"text-lighttext dark:text-darktext text-sm",
"hover:border-lightline-hover hover:text-trptkblue dark:hover:border-darkline-hover dark:hover:text-white",
"transition-all duration-200 ease-in-out",
"disabled:pointer-events-none disabled:opacity-50",
];
export const iconButtonMiniClass = miniClasses.join(" ");
type IconButtonMiniProps = ComponentPropsWithoutRef<"button"> & {
children: ReactNode;
};
export const IconButtonMini = forwardRef<HTMLButtonElement, IconButtonMiniProps>(
function IconButtonMini({ children, className, ...props }, ref) {
return (
<button
ref={ref}
type="button"
className={[...miniClasses, className].filter(Boolean).join(" ")}
{...props}
>
{children}
</button>
);
},
);
type IconButtonMiniLinkProps = Omit<ComponentPropsWithoutRef<"a">, keyof LinkProps> &
LinkProps & {
children: ReactNode;
};
export function IconButtonMiniLink({ children, className, ...props }: IconButtonMiniLinkProps) {
return (
<Link className={[...miniClasses, className].filter(Boolean).join(" ")} {...props}>
{children}
</Link>
);
}

View file

@ -0,0 +1,72 @@
import Link from "next/link";
import {
HiOutlineChevronDoubleLeft,
HiOutlineChevronLeft,
HiOutlineChevronRight,
HiOutlineChevronDoubleRight,
} from "react-icons/hi2";
import { IconButton, iconButtonClass } from "@/components/IconButton";
type PaginationNavProps = {
page: number;
totalPages: number;
buildHref: (page: number) => string;
className?: string;
};
export function PaginationNav({ page, totalPages, buildHref, className }: PaginationNavProps) {
if (totalPages <= 1) return null;
const hasPrev = page > 1;
const hasNext = page < totalPages;
return (
<nav aria-label="Pagination" className={className ?? "mt-12 flex items-center justify-between"}>
<div className="text-sm text-lightsec dark:text-darksec">
Page {page} of {totalPages}
</div>
<div className="flex gap-3 text-lg">
{hasPrev ? (
<Link href={buildHref(1)} className={iconButtonClass} aria-label="First page">
<HiOutlineChevronDoubleLeft />
</Link>
) : (
<IconButton disabled aria-label="First page">
<HiOutlineChevronDoubleLeft />
</IconButton>
)}
{hasPrev ? (
<Link href={buildHref(page - 1)} className={iconButtonClass} aria-label="Previous page">
<HiOutlineChevronLeft />
</Link>
) : (
<IconButton disabled aria-label="Previous page">
<HiOutlineChevronLeft />
</IconButton>
)}
{hasNext ? (
<Link href={buildHref(page + 1)} className={iconButtonClass} aria-label="Next page">
<HiOutlineChevronRight />
</Link>
) : (
<IconButton disabled aria-label="Next page">
<HiOutlineChevronRight />
</IconButton>
)}
{hasNext ? (
<Link href={buildHref(totalPages)} className={iconButtonClass} aria-label="Last page">
<HiOutlineChevronDoubleRight />
</Link>
) : (
<IconButton disabled aria-label="Last page">
<HiOutlineChevronDoubleRight />
</IconButton>
)}
</div>
</nav>
);
}

178
components/SearchBar.tsx Normal file
View file

@ -0,0 +1,178 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { IoCloseOutline } from "react-icons/io5";
import { useDebounced } from "@/hooks/useDebounced";
import { SortDropdown, type SortOption } from "@/components/SortDropdown";
import { FilterDropdown, type FilterGroup } from "@/components/FilterDropdown";
import { IconButton } from "@/components/IconButton";
const EMPTY_FILTERS: Record<string, string[]> = {};
type Props<T extends string> = {
initialQuery: string;
initialSort?: T;
initialPage?: number;
defaultSort: T;
placeholder: string;
sortOptions: SortOption<T>[];
sortAriaLabel: string;
sortMenuClassName?: string;
filterGroups?: FilterGroup[];
initialFilters?: Record<string, string[]>;
filterMenuClassName?: string;
};
export function SearchBar<T extends string>({
initialQuery,
initialSort,
initialPage = 1,
defaultSort,
placeholder,
sortOptions,
sortAriaLabel,
sortMenuClassName,
filterGroups,
initialFilters,
filterMenuClassName,
}: Props<T>) {
const sort0 = initialSort ?? defaultSort;
const filters0 = initialFilters ?? EMPTY_FILTERS;
const router = useRouter();
const pathname = usePathname();
const [q, setQ] = useState(initialQuery);
const debouncedQ = useDebounced(q, 250);
const [sort, setSort] = useState<T>(sort0);
const [filters, setFilters] = useState<Record<string, string[]>>(filters0);
const prevPropsRef = useRef({ initialQuery, initialSort: sort0, initialFilters: filters0 });
const lastPushedQRef = useRef(initialQuery);
useEffect(() => {
if (initialQuery !== lastPushedQRef.current) {
setQ(initialQuery);
}
lastPushedQRef.current = initialQuery;
}, [initialQuery]);
useEffect(() => {
setSort(sort0);
}, [sort0]);
useEffect(() => {
setFilters(filters0);
}, [filters0]);
const handleFilterChange = useCallback((param: string, selected: string[]) => {
setFilters((prev) => ({ ...prev, [param]: selected }));
}, []);
const filtersKey = useMemo(
() => JSON.stringify(filters, Object.keys(filters).sort()),
[filters],
);
const filters0Key = useMemo(
() => JSON.stringify(filters0, Object.keys(filters0).sort()),
[filters0],
);
const nextUrl = useMemo(() => {
const params = new URLSearchParams();
const normalizedQ = debouncedQ.trim();
const q0 = (initialQuery ?? "").trim();
const qChanged = normalizedQ !== q0;
const sortChanged = sort !== sort0;
const filtersChanged = filtersKey !== filters0Key;
if (normalizedQ) params.set("q", normalizedQ);
if (sort !== defaultSort) params.set("sort", sort);
for (const [param, selected] of Object.entries(filters)) {
if (selected.length > 0) params.set(param, selected.join(","));
}
if (!qChanged && !sortChanged && !filtersChanged && initialPage > 1) {
params.set("page", String(initialPage));
}
const qs = params.toString();
return qs ? `${pathname}?${qs}` : pathname;
}, [debouncedQ, initialQuery, initialPage, sort0, defaultSort, pathname, sort, filtersKey, filters0Key, filters]);
useEffect(() => {
const propsChanged =
prevPropsRef.current.initialQuery !== initialQuery ||
prevPropsRef.current.initialSort !== sort0 ||
JSON.stringify(prevPropsRef.current.initialFilters) !== JSON.stringify(filters0);
prevPropsRef.current = { initialQuery, initialSort: sort0, initialFilters: filters0 };
if (propsChanged) {
return;
}
const params = new URLSearchParams();
const q0 = (initialQuery ?? "").trim();
if (q0) params.set("q", q0);
if (sort0 && sort0 !== defaultSort) params.set("sort", sort0);
for (const [param, selected] of Object.entries(filters0)) {
if (selected.length > 0) params.set(param, selected.join(","));
}
if (initialPage > 1) params.set("page", String(initialPage));
const qs = params.toString();
const currentUrlFromProps = qs ? `${pathname}?${qs}` : pathname;
if (nextUrl !== currentUrlFromProps) {
lastPushedQRef.current = debouncedQ.trim();
router.replace(nextUrl, { scroll: false });
}
}, [nextUrl, debouncedQ, initialQuery, sort0, defaultSort, initialPage, pathname, router, filters0]);
return (
<div className="flex items-center gap-3">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={placeholder}
className="no-ring relative z-10 w-full rounded-xl border border-lightline bg-lightbg px-6 py-3 text-base text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue focus:border-lightline-focus dark:border-darkline dark:bg-darkbg dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white dark:focus:border-darkline-focus"
aria-label={placeholder}
/>
{q || Object.values(filters).some((arr) => arr.length > 0) ? (
<IconButton
onClick={() => {
setQ("");
setFilters(Object.fromEntries(Object.keys(filters).map((k) => [k, []])));
}}
className="text-lg"
aria-label="Clear search and filters"
>
<IoCloseOutline />
</IconButton>
) : null}
{filterGroups && filterGroups.length > 0 ? (
<FilterDropdown
groups={filterGroups}
values={filters}
onChange={handleFilterChange}
menuClassName={filterMenuClassName}
/>
) : null}
<SortDropdown
options={sortOptions}
value={sort}
onChange={setSort}
ariaLabel={sortAriaLabel}
menuClassName={sortMenuClassName}
/>
</div>
);
}

130
components/SortDropdown.tsx Normal file
View file

@ -0,0 +1,130 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { IoListOutline, IoArrowUpOutline, IoArrowDownOutline } from "react-icons/io5";
import { useClickOutside } from "@/hooks/useClickOutside";
import { IconButton } from "@/components/IconButton";
export type SortOption<T extends string = string> = {
value: T;
label: string;
iconDirection?: "asc" | "desc";
};
type Props<T extends string> = {
options: SortOption<T>[];
value: T;
onChange: (v: T) => void;
ariaLabel: string;
className?: string;
menuClassName?: string;
};
export function SortDropdown<T extends string>({
options,
value,
onChange,
ariaLabel,
className,
menuClassName,
}: Props<T>) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const listboxId = useId();
const active = options.find((o) => o.value === value) ?? options[0];
useClickOutside([buttonRef, menuRef], () => setOpen(false), open);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open]);
return (
<div className={`relative ${className ?? ""}`}>
<IconButton
ref={buttonRef}
onClick={() => setOpen((v) => !v)}
className="text-lg"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
>
<IoListOutline />
</IconButton>
<AnimatePresence>
{open ? (
<motion.div
ref={menuRef}
id={listboxId}
role="listbox"
aria-label={ariaLabel}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ type: "tween", ease: "easeInOut", duration: 0.25 }}
className={
menuClassName ??
"absolute right-0 z-20 mt-4 min-w-40 overflow-hidden rounded-2xl bg-lightbg shadow-lg ring-1 ring-lightline transition-colors duration-300 ease-in-out hover:ring-lightline-hover dark:bg-darkbg dark:ring-darkline dark:hover:ring-darkline-hover"
}
>
<div className="flex flex-col">
{options.map((opt, idx) => {
const selected = opt.value === active.value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={selected}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={[
"relative w-full p-4 text-left text-sm",
"transition-colors duration-300 ease-in-out",
selected
? "bg-lightline font-medium text-current dark:bg-darkline"
: "text-lightsec dark:text-darksec",
"hover:bg-lightline dark:hover:bg-darkline",
idx === 0 && "rounded-t-2xl",
idx === options.length - 1 && "rounded-b-2xl",
]
.filter(Boolean)
.join(" ")}
>
<span className="flex items-center justify-between gap-3">
<span>{opt.label}</span>
{opt.iconDirection ? (
<span className="shrink-0 text-base opacity-80">
{opt.iconDirection === "asc" ? (
<IoArrowUpOutline aria-hidden="true" />
) : (
<IoArrowDownOutline aria-hidden="true" />
)}
</span>
) : null}
</span>
</button>
);
})}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}

103
components/TabsClient.tsx Normal file
View file

@ -0,0 +1,103 @@
"use client";
import * as React from "react";
export type TabDef = {
id: string;
label: string;
content: React.ReactNode;
};
type Props = {
defaultTabId: string;
tabs: TabDef[];
};
function normalizeHash(hash: string) {
return hash.replace(/^#/, "").trim();
}
export function TabsClient({ defaultTabId, tabs }: Props) {
const tabIds = React.useMemo(() => new Set(tabs.map((t) => t.id)), [tabs]);
const [activeId, setActiveId] = React.useState(defaultTabId);
React.useEffect(() => {
const fromHash = normalizeHash(window.location.hash);
if (fromHash && tabIds.has(fromHash)) {
setActiveId(fromHash);
return;
}
const base = `${window.location.pathname}${window.location.search}`;
window.history.replaceState(null, "", `${base}#${defaultTabId}`);
}, [defaultTabId, tabIds]);
React.useEffect(() => {
const onHashChange = () => {
const next = normalizeHash(window.location.hash);
if (next && tabIds.has(next)) setActiveId(next);
};
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, [tabIds]);
const selectTab = React.useCallback((id: string) => {
setActiveId(id);
const base = `${window.location.pathname}${window.location.search}`;
window.history.replaceState(null, "", `${base}#${id}`);
}, []);
return (
<section className="w-full">
<div
role="tablist"
aria-label="Artist details"
className="relative z-10 my-10 flex w-full overflow-hidden rounded-2xl bg-lightbg text-lighttext shadow-lg ring-1 ring-lightline transition-all duration-300 ease-in-out hover:ring-lightline-hover md:my-12 lg:my-20 dark:bg-darkbg dark:text-darktext dark:ring-darkline dark:hover:ring-darkline-hover"
>
{tabs.map((t) => {
const active = t.id === activeId;
return (
<button
key={t.id}
role="tab"
type="button"
aria-selected={active}
aria-controls={`panel-${t.id}`}
id={`tab-${t.id}`}
onClick={() => selectTab(t.id)}
className={[
"relative flex-1 px-5 py-3 text-center text-sm transition-all duration-300 ease-in-out first:rounded-l-2xl last:rounded-r-2xl hover:bg-lightline sm:text-base dark:hover:bg-darkline",
active
? "bg-lightline font-medium text-current dark:bg-darkline"
: "text-lightsec dark:text-darksec",
].join(" ")}
>
{t.label}
</button>
);
})}
</div>
<div className="pt-4 sm:pt-0">
{tabs.map((t) => {
const active = t.id === activeId;
return (
<div
key={t.id}
role="tabpanel"
id={`panel-${t.id}`}
aria-labelledby={`tab-${t.id}`}
hidden={!active}
>
{t.content}
</div>
);
})}
</div>
</section>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import { TabsClient, type TabDef } from "@/components/TabsClient";
import { OrdersTab } from "./OrdersTab";
import { DownloadsTab } from "./DownloadsTab";
import { ProfileTab } from "./ProfileTab";
export function AccountTabs() {
const tabs: TabDef[] = [
{
id: "downloads",
label: "Downloads",
content: <DownloadsTab />,
},
{
id: "orders",
label: "Orders",
content: <OrdersTab />,
},
{
id: "profile",
label: "Profile",
content: <ProfileTab />,
},
];
return <TabsClient defaultTabId="downloads" tabs={tabs} />;
}

View file

@ -0,0 +1,277 @@
"use client";
import { useEffect, useState } from "react";
import { CountrySelect } from "@/components/CountrySelect";
type Address = {
id: string;
first_name: string;
last_name: string;
address_1: string;
address_2?: string;
city: string;
province?: string;
postal_code: string;
country_code: string;
phone?: string;
};
const inputClass =
"no-ring w-full rounded-xl border border-lightline px-6 py-3 shadow-lg text-lighttext transition-all duration-200 ease-in-out placeholder:text-lightsec hover:border-lightline-hover focus:border-lightline-focus dark:border-darkline dark:text-darktext dark:placeholder:text-darksec dark:hover:border-darkline-hover dark:focus:border-darkline-focus";
const emptyForm = {
first_name: "",
last_name: "",
address_1: "",
address_2: "",
city: "",
province: "",
postal_code: "",
country_code: "nl",
phone: "",
};
export function AddressesTab() {
const [address, setAddress] = useState<Address | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(emptyForm);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchAddress();
}, []);
async function fetchAddress() {
try {
const res = await fetch("/api/account/addresses");
if (res.ok) {
const data = await res.json();
const addresses: Address[] = data.addresses ?? [];
setAddress(addresses[0] ?? null);
}
} catch {
// Silently fail
} finally {
setLoading(false);
}
}
function startEdit() {
if (address) {
setForm({
first_name: address.first_name ?? "",
last_name: address.last_name ?? "",
address_1: address.address_1 ?? "",
address_2: address.address_2 ?? "",
city: address.city ?? "",
province: address.province ?? "",
postal_code: address.postal_code ?? "",
country_code: address.country_code ?? "nl",
phone: address.phone ?? "",
});
} else {
setForm(emptyForm);
}
setEditing(true);
setError(null);
}
function cancelForm() {
setEditing(false);
setForm(emptyForm);
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
const url = address ? `/api/account/addresses/${address.id}` : "/api/account/addresses";
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Failed to save address");
}
cancelForm();
await fetchAddress();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setSubmitting(false);
}
}
function updateField(field: string, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
}
if (loading) {
return <p className="py-12 text-center text-lightsec dark:text-darksec">Loading address</p>;
}
// Display mode: show the saved address (or empty state) with an Edit / Add button
if (!editing) {
if (address) {
return (
<div>
<p className="font-silkasb">
{address.first_name} {address.last_name}
</p>
<p className="text-sm text-lightsec dark:text-darksec">
{address.address_1}
{address.address_2 ? `, ${address.address_2}` : ""}
</p>
<p className="text-sm text-lightsec dark:text-darksec">
{address.city}
{address.province ? `, ${address.province}` : ""}, {address.postal_code},{" "}
{address.country_code.toUpperCase()}
</p>
{address.phone && (
<p className="text-sm text-lightsec dark:text-darksec">{address.phone}</p>
)}
<div className="mt-3">
<button
type="button"
onClick={startEdit}
className="text-sm text-trptkblue underline transition-opacity hover:opacity-70 dark:text-white"
>
Edit
</button>
</div>
</div>
);
}
return (
<div>
<p className="py-8 text-center text-lightsec dark:text-darksec">
No address saved. Add one to speed up checkout.
</p>
<button
type="button"
onClick={startEdit}
className="w-full rounded-xl border border-dashed border-lightline px-4 py-4 text-sm text-lightsec transition-all duration-200 hover:border-lightline-hover hover:text-trptkblue dark:border-darkline dark:text-darksec dark:hover:border-darkline-hover dark:hover:text-white"
>
+ Add Address
</button>
</div>
);
}
// Edit / Add form
return (
<form
onSubmit={handleSubmit}
className="rounded-2xl border border-lightline p-6 dark:border-darkline"
>
<h3 className="mb-4 font-silkasb text-sm">{address ? "Edit Address" : "Add Address"}</h3>
<div className="flex flex-col gap-3">
<div className="grid gap-3 sm:grid-cols-2">
<input
type="text"
required
placeholder="First name"
value={form.first_name}
onChange={(e) => updateField("first_name", e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Last name"
value={form.last_name}
onChange={(e) => updateField("last_name", e.target.value)}
className={inputClass}
/>
</div>
<input
type="text"
required
placeholder="Address"
value={form.address_1}
onChange={(e) => updateField("address_1", e.target.value)}
className={inputClass}
/>
<input
type="text"
placeholder="Apartment, suite, etc. (optional)"
value={form.address_2}
onChange={(e) => updateField("address_2", e.target.value)}
className={inputClass}
/>
<div className="grid gap-3 sm:grid-cols-3">
<input
type="text"
required
placeholder="City"
value={form.city}
onChange={(e) => updateField("city", e.target.value)}
className={inputClass}
/>
<input
type="text"
placeholder="Province / State"
value={form.province}
onChange={(e) => updateField("province", e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Postal code"
value={form.postal_code}
onChange={(e) => updateField("postal_code", e.target.value)}
className={inputClass}
/>
</div>
<CountrySelect
value={form.country_code}
onChange={(code) => updateField("country_code", code)}
className={inputClass}
/>
<input
type="tel"
placeholder="Phone (optional)"
value={form.phone}
onChange={(e) => updateField("phone", e.target.value)}
className={inputClass}
/>
</div>
{error && (
<div className="mt-3 rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
{error}
</div>
)}
<div className="mt-4 flex gap-3">
<button
type="submit"
disabled={submitting}
className="rounded-xl bg-trptkblue px-5 py-2.5 font-silkasb text-sm text-white shadow-lg transition-all duration-200 hover:opacity-90 disabled:pointer-events-none disabled:opacity-50 dark:bg-white dark:text-lighttext"
>
{submitting ? "Saving\u2026" : "Save Address"}
</button>
<button
type="button"
onClick={cancelForm}
className="rounded-xl border border-lightline px-5 py-2.5 text-sm transition-all duration-200 hover:border-lightline-hover hover:text-trptkblue dark:border-darkline dark:hover:border-darkline-hover dark:hover:text-white"
>
Cancel
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,142 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { HiOutlineArrowDownTray } from "react-icons/hi2";
import { urlFor } from "@/lib/sanityImage";
import type { SanityImageSource } from "@sanity/image-url";
export type DownloadFormat = {
variant_title: string;
sku: string;
download_url: string;
};
export type UpcomingFormat = {
variant_title: string;
sku: string;
};
export type DownloadCardData = {
product_title: string;
albumCover: SanityImageSource | null;
albumArtist: string | null;
slug: string | null;
catalogNo: string | null;
releaseDate: string | null;
genre: string[];
instrumentation: string[];
formats: DownloadFormat[];
};
export type UpcomingCardData = {
product_title: string;
albumCover: SanityImageSource | null;
albumArtist: string | null;
slug: string | null;
catalogNo: string | null;
releaseDate: string | null;
genre: string[];
instrumentation: string[];
release_date: string;
formats: UpcomingFormat[];
};
function formatReleaseMonth(dateString?: string) {
if (!dateString) return null;
return new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
}).format(new Date(dateString));
}
const downloadPillClass = [
"inline-flex items-center gap-1.5",
"text-sm text-lightsec dark:text-darksec",
"hover:text-trptkblue dark:hover:text-white",
"transition-all duration-200 ease-in-out",
].join(" ");
export function DownloadCard({ item }: { item: DownloadCardData }) {
const coverSrc = item.albumCover ? urlFor(item.albumCover).url() : null;
const href = item.slug ? `/release/${item.slug}` : "#";
return (
<div className="group flex flex-col rounded-xl shadow-lg ring-1 ring-lightline transition-all duration-300 ease-in-out hover:ring-lightline-hover dark:ring-darkline dark:hover:ring-darkline-hover">
<div className="block">
<div className="relative aspect-square w-full overflow-hidden rounded-xl">
{coverSrc ? (
<Image
src={coverSrc}
alt={`Album cover for ${item.product_title}`}
fill
className="object-cover"
sizes="(max-width: 560px) calc(100vw - 32px), 450px"
/>
) : (
<div className="absolute inset-0 bg-lightline-mid dark:bg-darkline-mid" />
)}
</div>
<div className="p-4 pb-0 sm:p-5 sm:pb-0">
<h3 className="mb-2 break-words">{item.product_title}</h3>
{item.albumArtist && (
<h4 className="text-sm break-words text-lightsec dark:text-darksec">
{item.albumArtist}
</h4>
)}
</div>
</div>
{/* Format download buttons */}
<div className="mt-auto flex flex-col gap-1 p-4 sm:p-5">
{item.formats.map((fmt) => (
<a key={fmt.sku} href={fmt.download_url} className={downloadPillClass}>
<HiOutlineArrowDownTray className="shrink-0" />
<span className="truncate">{fmt.variant_title}</span>
</a>
))}
</div>
</div>
);
}
export function UpcomingCard({ item }: { item: UpcomingCardData }) {
const coverSrc = item.albumCover ? urlFor(item.albumCover).url() : null;
const href = item.slug ? `/release/${item.slug}` : "#";
return (
<Link
href={href}
className="group relative rounded-xl shadow-lg ring-1 ring-lightline transition-all duration-300 ease-in-out hover:text-trptkblue hover:ring-lightline-hover dark:ring-darkline dark:hover:text-white dark:hover:ring-darkline-hover"
>
<div className="relative aspect-square w-full overflow-hidden rounded-t-xl opacity-60">
{coverSrc ? (
<Image
src={coverSrc}
alt={`Album cover for ${item.product_title}`}
fill
className="object-cover"
sizes="(max-width: 560px) calc(100vw - 32px), 450px"
/>
) : (
<div className="absolute inset-0 bg-lightline-mid dark:bg-darkline-mid" />
)}
</div>
<div className="p-4 sm:p-5 sm:pb-15">
<h3 className="mb-2 break-words">{item.product_title}</h3>
{item.albumArtist && (
<h4 className="text-sm break-words text-lightsec dark:text-darksec">
{item.albumArtist}
</h4>
)}
</div>
<div className="absolute right-4 bottom-4 left-4 hidden items-center justify-between text-lightsec opacity-50 sm:right-5 sm:bottom-5 sm:left-5 sm:flex dark:text-darksec">
<span className="text-sm">{item.catalogNo}</span>
<span className="text-sm">{formatReleaseMonth(item.release_date)}</span>
</div>
</Link>
);
}

View file

@ -0,0 +1,379 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { IoCloseOutline } from "react-icons/io5";
import {
HiOutlineChevronDoubleLeft,
HiOutlineChevronLeft,
HiOutlineChevronRight,
HiOutlineChevronDoubleRight,
} from "react-icons/hi2";
import { SortDropdown, type SortOption } from "@/components/SortDropdown";
import { FilterDropdown, type FilterGroup } from "@/components/FilterDropdown";
import { IconButton } from "@/components/IconButton";
import { CARD_GRID_CLASSES_3 } from "@/lib/constants";
import {
DownloadCard,
UpcomingCard,
type DownloadCardData,
type UpcomingCardData,
} from "./DownloadCard";
const PAGE_SIZE = 12;
// ── Sort ────────────────────────────────────────────────────────────
type SortMode =
| "titleAsc"
| "titleDesc"
| "catalogNoAsc"
| "catalogNoDesc"
| "releaseDateAsc"
| "releaseDateDesc";
const SORT_OPTIONS: SortOption<SortMode>[] = [
{ value: "titleAsc", label: "Title", iconDirection: "asc" },
{ value: "titleDesc", label: "Title", iconDirection: "desc" },
{ value: "catalogNoAsc", label: "Cat. No.", iconDirection: "asc" },
{ value: "catalogNoDesc", label: "Cat. No.", iconDirection: "desc" },
{ value: "releaseDateAsc", label: "Release date", iconDirection: "asc" },
{ value: "releaseDateDesc", label: "Release date", iconDirection: "desc" },
];
// ── Filters ─────────────────────────────────────────────────────────
const GENRE_OPTIONS = [
{ value: "earlyMusic", label: "Early Music" },
{ value: "baroque", label: "Baroque" },
{ value: "classical", label: "Classical" },
{ value: "romantic", label: "Romantic" },
{ value: "contemporary", label: "Contemporary" },
{ value: "worldMusic", label: "World Music" },
{ value: "jazz", label: "Jazz" },
{ value: "crossover", label: "Crossover" },
{ value: "electronic", label: "Electronic" },
{ value: "minimal", label: "Minimal" },
{ value: "popRock", label: "Pop / Rock" },
];
const INSTRUMENTATION_OPTIONS = [
{ value: "solo", label: "Solo" },
{ value: "chamber", label: "Chamber" },
{ value: "ensemble", label: "Ensemble" },
{ value: "orchestra", label: "Orchestral" },
{ value: "vocalChoral", label: "Vocal / Choral" },
];
const FILTER_GROUPS: FilterGroup[] = [
{ label: "Genre", param: "genre", options: GENRE_OPTIONS },
{ label: "Instrumentation", param: "instrumentation", options: INSTRUMENTATION_OPTIONS },
];
// ── Helpers ─────────────────────────────────────────────────────────
type Sortable = {
product_title: string;
catalogNo?: string | null;
releaseDate?: string | null;
};
function compareStr(a?: string | null, b?: string | null): number {
return (a ?? "").localeCompare(b ?? "", undefined, { sensitivity: "base" });
}
function compareDate(a?: string | null, b?: string | null, asc = true): number {
const da = a ?? (asc ? "9999-12-31" : "0000-01-01");
const db = b ?? (asc ? "9999-12-31" : "0000-01-01");
return da < db ? -1 : da > db ? 1 : 0;
}
function sortItems<T extends Sortable>(list: T[], mode: SortMode): T[] {
return [...list].sort((a, b) => {
switch (mode) {
case "titleAsc":
return compareStr(a.product_title, b.product_title);
case "titleDesc":
return compareStr(b.product_title, a.product_title);
case "catalogNoAsc":
return compareStr(a.catalogNo, b.catalogNo) || compareStr(a.product_title, b.product_title);
case "catalogNoDesc":
return compareStr(b.catalogNo, a.catalogNo) || compareStr(a.product_title, b.product_title);
case "releaseDateAsc":
return (
compareDate(a.releaseDate, b.releaseDate, true) ||
compareStr(a.product_title, b.product_title)
);
case "releaseDateDesc":
return (
compareDate(b.releaseDate, a.releaseDate, false) ||
compareStr(a.product_title, b.product_title)
);
}
});
}
type Searchable = {
product_title: string;
albumArtist?: string | null;
catalogNo?: string | null;
};
function matchesSearch(item: Searchable, lower: string): boolean {
return (
item.product_title.toLowerCase().includes(lower) ||
(item.albumArtist?.toLowerCase().includes(lower) ?? false) ||
(item.catalogNo?.toLowerCase().includes(lower) ?? false)
);
}
type Filterable = {
genre?: string[];
instrumentation?: string[];
};
function matchesFilters(item: Filterable, filters: Record<string, string[]>): boolean {
const genres = filters.genre ?? [];
const instrumentations = filters.instrumentation ?? [];
if (genres.length > 0 && !genres.some((g) => item.genre?.includes(g))) return false;
if (
instrumentations.length > 0 &&
!instrumentations.some((i) => item.instrumentation?.includes(i))
)
return false;
return true;
}
// ── Component ───────────────────────────────────────────────────────
export function DownloadsTab() {
const [downloads, setDownloads] = useState<DownloadCardData[]>([]);
const [upcoming, setUpcoming] = useState<UpcomingCardData[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState("");
const [sort, setSort] = useState<SortMode>("releaseDateDesc");
const [filters, setFilters] = useState<Record<string, string[]>>({});
const [dlPage, setDlPage] = useState(1);
const [upPage, setUpPage] = useState(1);
useEffect(() => {
async function fetchDownloads() {
try {
const res = await fetch("/api/account/downloads");
if (res.ok) {
const data = await res.json();
if (data.downloads?.length) setDownloads(data.downloads);
if (data.upcoming?.length) setUpcoming(data.upcoming);
}
} catch {
// Silently fail
} finally {
setLoading(false);
}
}
fetchDownloads();
}, []);
const resetPages = useCallback(() => {
setDlPage(1);
setUpPage(1);
}, []);
const handleFilterChange = useCallback((param: string, selected: string[]) => {
setFilters((prev) => ({ ...prev, [param]: selected }));
setDlPage(1);
setUpPage(1);
}, []);
const handleSortChange = useCallback(
(value: SortMode) => {
setSort(value);
resetPages();
},
[resetPages],
);
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setQ(e.target.value);
resetPages();
},
[resetPages],
);
const clearSearch = useCallback(() => {
setQ("");
resetPages();
}, [resetPages]);
const filteredDownloads = useMemo(() => {
const lower = q.trim().toLowerCase();
let result = downloads;
if (lower) result = result.filter((d) => matchesSearch(d, lower));
result = result.filter((d) => matchesFilters(d, filters));
return sortItems(result, sort);
}, [downloads, q, sort, filters]);
const filteredUpcoming = useMemo(() => {
const lower = q.trim().toLowerCase();
let result = upcoming;
if (lower) result = result.filter((u) => matchesSearch(u, lower));
result = result.filter((u) => matchesFilters(u, filters));
return sortItems(result, sort);
}, [upcoming, q, sort, filters]);
const dlTotalPages = Math.max(1, Math.ceil(filteredDownloads.length / PAGE_SIZE));
const dlSafePage = Math.min(dlPage, dlTotalPages);
const dlStart = (dlSafePage - 1) * PAGE_SIZE;
const paginatedDownloads = filteredDownloads.slice(dlStart, dlStart + PAGE_SIZE);
const upTotalPages = Math.max(1, Math.ceil(filteredUpcoming.length / PAGE_SIZE));
const upSafePage = Math.min(upPage, upTotalPages);
const upStart = (upSafePage - 1) * PAGE_SIZE;
const paginatedUpcoming = filteredUpcoming.slice(upStart, upStart + PAGE_SIZE);
if (loading) {
return <p className="py-12 text-center text-lightsec dark:text-darksec">Loading downloads</p>;
}
if (downloads.length === 0 && upcoming.length === 0) {
return (
<p className="py-12 text-center text-lightsec dark:text-darksec">
No downloads available. Your digital purchases will appear here.
</p>
);
}
const totalFiltered = filteredDownloads.length + filteredUpcoming.length;
return (
<div>
{/* Count */}
<div className="mb-4">
<p className="text-sm text-lightsec dark:text-darksec">
{totalFiltered ? `${totalFiltered} release(s)` : "No releases found."}
</p>
</div>
{/* Search + Filter + Sort bar */}
<div className="mb-12 flex items-center gap-3">
<input
value={q}
onChange={handleSearchChange}
placeholder="Search releases…"
className="no-ring w-full rounded-xl border border-lightline px-6 py-3 text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue focus:border-lightline-focus dark:border-darkline dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white dark:focus:border-darkline-focus"
aria-label="Search releases"
/>
{q ? (
<IconButton onClick={clearSearch} className="text-lg" aria-label="Clear search">
<IoCloseOutline />
</IconButton>
) : null}
<FilterDropdown groups={FILTER_GROUPS} values={filters} onChange={handleFilterChange} />
<SortDropdown
options={SORT_OPTIONS}
value={sort}
onChange={handleSortChange}
ariaLabel="Sort releases"
/>
</div>
{/* Available Downloads */}
{paginatedDownloads.length > 0 && (
<section>
<div className={CARD_GRID_CLASSES_3}>
{paginatedDownloads.map((dl) => (
<DownloadCard key={dl.product_title} item={dl} />
))}
</div>
{dlTotalPages > 1 && (
<Pagination page={dlSafePage} totalPages={dlTotalPages} onPageChange={setDlPage} />
)}
</section>
)}
{/* Upcoming Releases */}
{paginatedUpcoming.length > 0 && (
<section className={paginatedDownloads.length > 0 ? "mt-16" : ""}>
<div className="mb-4">
<h2 className="font-silkasb text-sm tracking-wider uppercase">Upcoming Releases</h2>
<p className="mt-1 text-xs text-lightsec dark:text-darksec">
You&apos;ll receive download links by email when these become available.
</p>
</div>
<div className={CARD_GRID_CLASSES_3}>
{paginatedUpcoming.map((up) => (
<UpcomingCard key={up.product_title} item={up} />
))}
</div>
{upTotalPages > 1 && (
<Pagination page={upSafePage} totalPages={upTotalPages} onPageChange={setUpPage} />
)}
</section>
)}
{/* No results after filtering */}
{totalFiltered === 0 && (q || Object.values(filters).some((v) => v.length > 0)) && (
<p className="py-12 text-center text-lightsec dark:text-darksec">No releases found.</p>
)}
</div>
);
}
// ── Pagination ──────────────────────────────────────────────────────
function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (p: number) => void;
}) {
const hasPrev = page > 1;
const hasNext = page < totalPages;
return (
<nav aria-label="Pagination" className="mt-12 flex items-center justify-between">
<div className="text-sm text-lightsec dark:text-darksec">
Page {page} of {totalPages}
</div>
<div className="flex gap-3 text-lg">
<IconButton disabled={!hasPrev} onClick={() => onPageChange(1)} aria-label="First page">
<HiOutlineChevronDoubleLeft />
</IconButton>
<IconButton
disabled={!hasPrev}
onClick={() => onPageChange(page - 1)}
aria-label="Previous page"
>
<HiOutlineChevronLeft />
</IconButton>
<IconButton
disabled={!hasNext}
onClick={() => onPageChange(page + 1)}
aria-label="Next page"
>
<HiOutlineChevronRight />
</IconButton>
<IconButton
disabled={!hasNext}
onClick={() => onPageChange(totalPages)}
aria-label="Last page"
>
<HiOutlineChevronDoubleRight />
</IconButton>
</div>
</nav>
);
}

View file

@ -0,0 +1,274 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import {
HiOutlineChevronDoubleLeft,
HiOutlineChevronLeft,
HiOutlineChevronRight,
HiOutlineChevronDoubleRight,
} from "react-icons/hi2";
import { IconButton } from "@/components/IconButton";
import { formatDisplayName } from "@/lib/variants";
const PAGE_SIZE = 5;
type OrderItem = {
id: string;
title: string;
product_title: string;
variant_title: string;
variant_sku?: string;
variant?: { sku?: string };
quantity: number;
unit_price: number;
total: number;
subtotal?: number;
tax_total?: number;
thumbnail: string | null;
};
type Order = {
id: string;
display_id: number;
created_at: string;
total: number;
subtotal?: number;
shipping_total?: number;
tax_total?: number;
currency_code: string;
items: OrderItem[];
};
function formatPrice(amount: number, currencyCode: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
minimumFractionDigits: 2,
}).format(amount);
}
function variantLabel(item: OrderItem): string {
const sku = item.variant_sku ?? item.variant?.sku;
if (sku) {
const friendly = formatDisplayName(sku);
if (friendly) return friendly;
}
return item.variant_title || item.title;
}
export function OrdersTab() {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
useEffect(() => {
async function fetchOrders() {
try {
const res = await fetch("/api/account/orders");
if (res.ok) {
const data = await res.json();
// Sort newest first (fallback in case API doesn't sort)
const sorted = [...(data.orders ?? [])].sort(
(a: Order, b: Order) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
setOrders(sorted);
}
} catch {
// Silently fail — empty state will show
} finally {
setLoading(false);
}
}
fetchOrders();
}, []);
if (loading) {
return (
<p className="my-10 text-center text-lightsec md:my-12 lg:my-20 dark:text-darksec">
Loading orders
</p>
);
}
if (orders.length === 0) {
return (
<p className="py-12 text-center text-lightsec dark:text-darksec">
You haven&apos;t placed any orders yet.
</p>
);
}
const totalPages = Math.max(1, Math.ceil(orders.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * PAGE_SIZE;
const paginatedOrders = orders.slice(start, start + PAGE_SIZE);
return (
<div>
<div className="mb-4">
<p className="text-sm text-lightsec dark:text-darksec">
{orders.length} order{orders.length !== 1 ? "s" : ""}
</p>
</div>
<div className="divide-y divide-lighttext/10 dark:divide-darktext/10">
{paginatedOrders.map((order) => {
const currency = order.currency_code ?? "eur";
const subtotal =
order.subtotal ?? order.items?.reduce((sum, i) => sum + (i.subtotal ?? 0), 0) ?? 0;
const taxTotal =
order.tax_total ?? order.items?.reduce((sum, i) => sum + (i.tax_total ?? 0), 0) ?? 0;
const shippingTotal =
order.shipping_total ?? Math.max(0, order.total - subtotal - taxTotal);
return (
<div key={order.id} className="py-12 first:pt-0 last:pb-0">
{/* Order header */}
<div className="flex items-center justify-between">
<div>
<span className="font-silkasb">Order #{order.display_id}</span>
<span className="ml-3 text-sm text-lightsec dark:text-darksec">
{new Date(order.created_at).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
</div>
</div>
{/* Item cards */}
{order.items?.length > 0 && (
<div className="mt-6">
<div className="grid gap-4 md:grid-cols-2">
{order.items.map((item) => (
<div
key={item.id}
className="relative z-10 flex items-center gap-3 overflow-hidden rounded-xl bg-lightbg shadow-lg ring-1 ring-lightline dark:bg-darkbg dark:ring-darkline"
>
{/* Thumbnail */}
<div className="relative aspect-square w-21 flex-shrink-0 overflow-hidden rounded-xl bg-lightline dark:bg-darkline">
{item.thumbnail ? (
<Image
src={item.thumbnail}
alt={item.product_title ?? item.title}
fill
sizes="64px"
className="object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-lightsec dark:text-darksec">
No img
</div>
)}
</div>
{/* Info */}
<div className="min-w-0 flex-1 py-3 pr-3">
<p className="truncate font-silkasb text-sm">
{item.product_title ?? item.title}
</p>
<p className="truncate text-xs text-lightsec dark:text-darksec">
{variantLabel(item)}
{item.quantity >= 2 && (
<span className="ml-1 font-silka text-lightsec dark:text-darksec">
x {item.quantity}
</span>
)}
</p>
</div>
</div>
))}
</div>
{/* Price breakdown */}
<div className="mt-7 space-y-1">
<div className="flex justify-between text-sm">
<span className="text-lightsec dark:text-darksec">Subtotal</span>
<span>{formatPrice(subtotal, currency)}</span>
</div>
{shippingTotal > 0 && (
<div className="flex justify-between text-sm">
<span className="text-lightsec dark:text-darksec">Shipping</span>
<span>{formatPrice(shippingTotal, currency)}</span>
</div>
)}
{taxTotal > 0 && (
<div className="flex justify-between text-sm">
<span className="text-lightsec dark:text-darksec">Tax</span>
<span>{formatPrice(taxTotal, currency)}</span>
</div>
)}
{taxTotal > 0 && (
<div className="flex justify-between font-silkasb text-sm">
<span className="">Total</span>
<span className="">{formatPrice(order.total, currency)}</span>
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
{totalPages > 1 && (
<Pagination page={safePage} totalPages={totalPages} onPageChange={setPage} />
)}
</div>
);
}
// ── Pagination ──────────────────────────────────────────────────────
function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (p: number) => void;
}) {
const hasPrev = page > 1;
const hasNext = page < totalPages;
return (
<nav aria-label="Pagination" className="mt-12 flex items-center justify-between">
<div className="text-sm text-lightsec dark:text-darksec">
Page {page} of {totalPages}
</div>
<div className="flex gap-3 text-lg">
<IconButton disabled={!hasPrev} onClick={() => onPageChange(1)} aria-label="First page">
<HiOutlineChevronDoubleLeft />
</IconButton>
<IconButton
disabled={!hasPrev}
onClick={() => onPageChange(page - 1)}
aria-label="Previous page"
>
<HiOutlineChevronLeft />
</IconButton>
<IconButton
disabled={!hasNext}
onClick={() => onPageChange(page + 1)}
aria-label="Next page"
>
<HiOutlineChevronRight />
</IconButton>
<IconButton
disabled={!hasNext}
onClick={() => onPageChange(totalPages)}
aria-label="Last page"
>
<HiOutlineChevronDoubleRight />
</IconButton>
</div>
</nav>
);
}

View file

@ -0,0 +1,14 @@
"use client";
import { AddressesTab } from "./AddressesTab";
export function ProfileTab() {
return (
<div className="space-y-12">
<section>
<h2 className="mb-4 font-silkasb">Addresses</h2>
<AddressesTab />
</section>
</div>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useMemo, useState } from "react";
import { urlFor } from "@/lib/sanityImage";
import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants";
import type { SanityImageSource } from "@sanity/image-url";
export type ArtistCardProps = {
name?: string;
subtitle?: string;
image?: SanityImageSource;
href: string;
label?: string;
className?: string;
};
export function useCardImage(image?: SanityImageSource) {
const [imgError, setImgError] = useState(false);
const src = useMemo(() => {
if (imgError || !image) return ARTIST_PLACEHOLDER_SRC;
return urlFor(image).url();
}, [image, imgError]);
return { src, onError: () => setImgError(true) };
}
export function ArtistCard({
name,
subtitle,
image,
href,
label = "Artist",
className = "",
}: ArtistCardProps) {
const { src, onError } = useCardImage(image);
return (
<Link
href={href}
className={[
"group relative z-10 rounded-xl bg-lightbg shadow-lg ring-1 ring-lightline dark:bg-darkbg dark:ring-darkline",
"hover:text-trptkblue hover:ring-lightline-hover dark:hover:text-white dark:hover:ring-darkline-hover",
"transition-color duration-300 ease-in-out",
className,
].join(" ")}
aria-label={name ? `${label}: ${name}` : label}
>
<div className="relative aspect-square w-full overflow-hidden rounded-xl">
<Image
src={src}
alt={name ? `Photo of ${name}` : `${label} photo`}
fill
sizes="(max-width: 560px) calc(100vw - 32px), 450px"
className="object-cover"
onError={onError}
/>
</div>
<div className="p-3 sm:p-5">
<h3 className="text-sm break-words sm:mb-1 sm:text-base md:mb-2">
{name ?? `Untitled ${label.toLowerCase()}`}
</h3>
{subtitle ? (
<h4 className="hidden text-sm break-words text-lightsec sm:block dark:text-darksec">
{subtitle}
</h4>
) : null}
</div>
</Link>
);
}

View file

@ -0,0 +1,51 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useCardImage, type ArtistCardProps } from "./ArtistCard";
export function ArtistCardCompact({
name,
subtitle,
image,
href,
label = "Artist",
className = "",
}: ArtistCardProps) {
const { src, onError } = useCardImage(image);
return (
<Link
href={href}
className={[
"group relative z-10 flex h-16 items-center overflow-hidden rounded-lg bg-lightbg shadow-lg ring-1 ring-lightline dark:bg-darkbg dark:ring-darkline",
"hover:text-trptkblue hover:ring-lightline-hover dark:hover:text-white dark:hover:ring-darkline-hover",
"transition-color duration-300 ease-in-out",
className,
].join(" ")}
aria-label={name ? `${label}: ${name}` : label}
>
<div className="relative aspect-square h-full shrink-0">
<Image
src={src}
alt={name ? `Photo of ${name}` : `${label} photo`}
fill
sizes="96px"
className="rounded-lg object-cover"
onError={onError}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col justify-center p-3">
<h3 className="truncate text-sm leading-snug">
{name ?? `Untitled ${label.toLowerCase()}`}
</h3>
{subtitle && (
<h4 className="mt-1 truncate text-xs leading-relaxed text-lightsec dark:text-darksec">
{subtitle}
</h4>
)}
</div>
</Link>
);
}

View file

@ -0,0 +1,25 @@
import { ConcertTable, type ConcertData } from "@/components/concert/ConcertTable";
type Props = {
upcoming: ConcertData[];
past: ConcertData[];
};
export function ArtistConcertsTab({ upcoming, past }: Props) {
return (
<div className="space-y-12">
{upcoming.length > 0 && (
<section>
<h3 className="mb-4 font-silkasb text-base">Upcoming concerts</h3>
<ConcertTable concerts={upcoming} />
</section>
)}
{past.length > 0 && (
<section>
<h3 className="mb-4 font-silkasb text-base">Past concerts</h3>
<ConcertTable concerts={past} past />
</section>
)}
</div>
);
}

View file

@ -0,0 +1,265 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import type { SanityImageSource } from "@sanity/image-url";
import { IoCloseOutline } from "react-icons/io5";
import {
HiOutlineChevronDoubleLeft,
HiOutlineChevronLeft,
HiOutlineChevronRight,
HiOutlineChevronDoubleRight,
} from "react-icons/hi2";
import { ReleaseCard } from "@/components/release/ReleaseCard";
import { SortDropdown, type SortOption } from "@/components/SortDropdown";
import { FilterDropdown, type FilterGroup } from "@/components/FilterDropdown";
import { IconButton } from "@/components/IconButton";
import { CARD_GRID_CLASSES_3 } from "@/lib/constants";
export type ArtistRelease = {
_id?: string;
name?: string;
albumArtist?: string;
catalogNo?: string;
releaseDate?: string;
slug?: string;
albumCover?: SanityImageSource;
genre?: string[];
instrumentation?: string[];
};
const PAGE_SIZE = 12;
type SortMode =
| "releaseDateDesc"
| "releaseDateAsc"
| "titleAsc"
| "titleDesc"
| "catalogNoAsc"
| "catalogNoDesc";
const SORT_OPTIONS: SortOption<SortMode>[] = [
{ value: "titleAsc", label: "Title", iconDirection: "asc" },
{ value: "titleDesc", label: "Title", iconDirection: "desc" },
{ value: "catalogNoAsc", label: "Cat. No.", iconDirection: "asc" },
{ value: "catalogNoDesc", label: "Cat. No.", iconDirection: "desc" },
{ value: "releaseDateAsc", label: "Release date", iconDirection: "asc" },
{ value: "releaseDateDesc", label: "Release date", iconDirection: "desc" },
];
const GENRE_OPTIONS = [
{ value: "earlyMusic", label: "Early Music" },
{ value: "baroque", label: "Baroque" },
{ value: "classical", label: "Classical" },
{ value: "romantic", label: "Romantic" },
{ value: "contemporary", label: "Contemporary" },
{ value: "worldMusic", label: "World Music" },
{ value: "jazz", label: "Jazz" },
{ value: "crossover", label: "Crossover" },
{ value: "electronic", label: "Electronic" },
{ value: "minimal", label: "Minimal" },
{ value: "popRock", label: "Pop / Rock" },
];
const INSTRUMENTATION_OPTIONS = [
{ value: "solo", label: "Solo" },
{ value: "chamber", label: "Chamber" },
{ value: "ensemble", label: "Ensemble" },
{ value: "orchestra", label: "Orchestral" },
{ value: "vocalChoral", label: "Vocal / Choral" },
];
const FILTER_GROUPS: FilterGroup[] = [
{ label: "Genre", param: "genre", options: GENRE_OPTIONS },
{ label: "Instrumentation", param: "instrumentation", options: INSTRUMENTATION_OPTIONS },
];
function compareStr(a?: string, b?: string): number {
return (a ?? "").localeCompare(b ?? "", undefined, { sensitivity: "base" });
}
function compareDate(a?: string, b?: string, asc = true): number {
const da = a ?? (asc ? "9999-12-31" : "0000-01-01");
const db = b ?? (asc ? "9999-12-31" : "0000-01-01");
return da < db ? -1 : da > db ? 1 : 0;
}
function sortReleases(list: ArtistRelease[], mode: SortMode): ArtistRelease[] {
return [...list].sort((a, b) => {
switch (mode) {
case "titleAsc":
return compareStr(a.name, b.name);
case "titleDesc":
return compareStr(b.name, a.name);
case "catalogNoAsc":
return compareStr(a.catalogNo, b.catalogNo) || compareStr(a.name, b.name);
case "catalogNoDesc":
return compareStr(b.catalogNo, a.catalogNo) || compareStr(a.name, b.name);
case "releaseDateAsc":
return compareDate(a.releaseDate, b.releaseDate, true) || compareStr(a.name, b.name);
case "releaseDateDesc":
return compareDate(b.releaseDate, a.releaseDate, false) || compareStr(a.name, b.name);
}
});
}
function matchesSearch(r: ArtistRelease, lower: string): boolean {
return (
(r.name?.toLowerCase().includes(lower) ?? false) ||
(r.albumArtist?.toLowerCase().includes(lower) ?? false) ||
(r.catalogNo?.toLowerCase().includes(lower) ?? false)
);
}
function matchesFilters(r: ArtistRelease, filters: Record<string, string[]>): boolean {
const genres = filters.genre ?? [];
const instrumentations = filters.instrumentation ?? [];
if (genres.length > 0 && !genres.some((g) => r.genre?.includes(g))) return false;
if (instrumentations.length > 0 && !instrumentations.some((i) => r.instrumentation?.includes(i)))
return false;
return true;
}
type Props = {
releases: ArtistRelease[];
};
export function ArtistReleasesTab({ releases }: Props) {
const [q, setQ] = useState("");
const [sort, setSort] = useState<SortMode>("releaseDateDesc");
const [filters, setFilters] = useState<Record<string, string[]>>({});
const [page, setPage] = useState(1);
const handleFilterChange = useCallback((param: string, selected: string[]) => {
setFilters((prev) => ({ ...prev, [param]: selected }));
setPage(1);
}, []);
const handleSortChange = useCallback((value: SortMode) => {
setSort(value);
setPage(1);
}, []);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setQ(e.target.value);
setPage(1);
}, []);
const filtered = useMemo(() => {
const lower = q.trim().toLowerCase();
let result = releases;
if (lower) result = result.filter((r) => matchesSearch(r, lower));
result = result.filter((r) => matchesFilters(r, filters));
return sortReleases(result, sort);
}, [releases, q, sort, filters]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * PAGE_SIZE;
const paginated = filtered.slice(start, start + PAGE_SIZE);
return (
<div>
<div className="mb-4">
<p className="text-sm text-lightsec dark:text-darksec">
{filtered.length ? `${filtered.length} release(s)` : "No releases found."}
</p>
</div>
<div className="mb-12 flex items-center gap-3">
<input
value={q}
onChange={handleSearchChange}
placeholder="Search releases…"
className="no-ring w-full rounded-xl border border-lightline px-6 py-3 text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue focus:border-lightline-focus dark:border-darkline dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white dark:focus:border-darkline-focus"
aria-label="Search releases"
/>
{q ? (
<IconButton
onClick={() => {
setQ("");
setPage(1);
}}
className="text-lg"
aria-label="Clear search"
>
<IoCloseOutline />
</IconButton>
) : null}
<FilterDropdown groups={FILTER_GROUPS} values={filters} onChange={handleFilterChange} />
<SortDropdown
options={SORT_OPTIONS}
value={sort}
onChange={handleSortChange}
ariaLabel="Sort releases"
/>
</div>
{paginated.length > 0 ? (
<section className={CARD_GRID_CLASSES_3}>
{paginated.map((r) => (
<ReleaseCard key={r._id ?? r.slug} release={r} />
))}
</section>
) : null}
{totalPages > 1 ? (
<Pagination page={safePage} totalPages={totalPages} onPageChange={setPage} />
) : null}
</div>
);
}
function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (p: number) => void;
}) {
const hasPrev = page > 1;
const hasNext = page < totalPages;
return (
<nav aria-label="Pagination" className="mt-12 flex items-center justify-between">
<div className="text-sm text-lightsec dark:text-darksec">
Page {page} of {totalPages}
</div>
<div className="flex gap-3 text-lg">
<IconButton disabled={!hasPrev} onClick={() => onPageChange(1)} aria-label="First page">
<HiOutlineChevronDoubleLeft />
</IconButton>
<IconButton
disabled={!hasPrev}
onClick={() => onPageChange(page - 1)}
aria-label="Previous page"
>
<HiOutlineChevronLeft />
</IconButton>
<IconButton
disabled={!hasNext}
onClick={() => onPageChange(page + 1)}
aria-label="Next page"
>
<HiOutlineChevronRight />
</IconButton>
<IconButton
disabled={!hasNext}
onClick={() => onPageChange(totalPages)}
aria-label="Last page"
>
<HiOutlineChevronDoubleRight />
</IconButton>
</div>
</nav>
);
}

View file

@ -0,0 +1,54 @@
import { TabsClient, type TabDef } from "@/components/TabsClient";
import { PortableText } from "@portabletext/react";
import { portableTextComponents } from "@/lib/portableTextComponents";
import { ArtistWorksTab, type ComposedWork } from "@/components/artist/ArtistWorksTab";
type Props = {
bio: any;
hasReleases?: boolean;
releasesTab?: React.ReactNode;
composedWorks?: ComposedWork[];
arrangedWorks?: ComposedWork[];
concerts?: React.ReactNode;
};
export function ArtistTabs({ bio, hasReleases, releasesTab, composedWorks, arrangedWorks, concerts }: Props) {
const tabs: TabDef[] = [
{
id: "bio",
label: "Biography",
content: (
<article className="prose max-w-none text-lighttext dark:prose-invert dark:text-darktext prose-h2:!mt-[3em] prose-h2:font-silkasb prose-h2:text-base">
<h2 className="sr-only">Biography</h2>
{bio ? <PortableText value={bio} components={portableTextComponents} /> : <p>No biography available yet.</p>}
</article>
),
},
];
if (composedWorks?.length || arrangedWorks?.length) {
tabs.push({
id: "works",
label: "Works",
content: <ArtistWorksTab composedWorks={composedWorks} arrangedWorks={arrangedWorks} />,
});
}
if (concerts) {
tabs.push({
id: "concerts",
label: "Concerts",
content: concerts,
});
}
if (hasReleases) {
tabs.push({
id: "releases",
label: "Releases",
content: releasesTab,
});
}
return <TabsClient defaultTabId="bio" tabs={tabs} />;
}

View file

@ -0,0 +1,95 @@
import { ArrowLink } from "@/components/ArrowLink";
export type ComposedWork = {
title?: string;
slug?: string;
originalComposerName?: string;
arrangerName?: string;
};
type Props = {
composedWorks?: ComposedWork[];
arrangedWorks?: ComposedWork[];
};
export function ArtistWorksTab({ composedWorks, arrangedWorks }: Props) {
if (!composedWorks?.length && !arrangedWorks?.length) return null;
return (
<div>
{composedWorks?.length ? (
<div className="flex flex-col md:flex-row">
<h3 className="mb-2 flex-none font-silkasb text-base md:mb-0 md:w-40">Compositions</h3>
<ul className="md:flex-1">
{composedWorks.map((w, idx) => {
const key = w.slug ?? `composed-${idx}`;
return (
<li key={key}>
{w.slug ? (
<ArrowLink href={`/work/${w.slug}`}>
{w.title}
{w.arrangerName && (
<span className="ml-2 text-lightsec opacity-50 dark:text-darksec">
{" "}
(arr. {w.arrangerName})
</span>
)}
</ArrowLink>
) : (
<>
{w.title}
{w.arrangerName && (
<span className="ml-2 text-lightsec opacity-50 dark:text-darksec">
{" "}
(arr. {w.arrangerName})
</span>
)}
</>
)}
</li>
);
})}
</ul>
</div>
) : null}
{composedWorks?.length && arrangedWorks?.length ? <div className="mb-8" /> : null}
{arrangedWorks?.length ? (
<div className="flex flex-col md:flex-row">
<h3 className="mb-2 font-silkasb text-base md:mb-0 md:w-40 md:flex-none">Arrangements</h3>
<ul className="md:flex-1">
{arrangedWorks.map((w, idx) => {
const key = w.slug ?? `arranged-${idx}`;
return (
<li key={key}>
{w.slug ? (
<ArrowLink href={`/work/${w.slug}`}>
{w.originalComposerName && (
<span className="mr-2 text-lightsec opacity-50 dark:text-darksec">
{w.originalComposerName}:{" "}
</span>
)}
{w.title}
</ArrowLink>
) : (
<>
{w.originalComposerName && (
<span className="mr-2 text-lightsec opacity-50 dark:text-darksec">
{w.originalComposerName}:{" "}
</span>
)}
{w.title}
</>
)}
</li>
);
})}
</ul>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,25 @@
import type { SanityImageSource } from "@sanity/image-url";
export type ArtistCardData = {
_id: string;
name?: string;
role?: string;
slug?: string;
image?: SanityImageSource;
};
export type ComposerCardData = {
_id: string;
name?: string;
sortKey?: string;
birthYear?: number;
deathYear?: number;
slug?: string;
image?: SanityImageSource;
};
export function formatYears(birthYear?: number, deathYear?: number): string {
if (birthYear && deathYear) return `${birthYear}\u2013${deathYear}`;
if (birthYear) return `${birthYear}`;
return "";
}

View file

@ -0,0 +1,19 @@
"use client";
import { IoPersonOutline } from "react-icons/io5";
import { IconButtonLink } from "@/components/IconButton";
import { useAuth } from "./AuthContext";
export function AccountButton() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return null;
const href = isAuthenticated ? "/account" : "/account/login";
return (
<IconButtonLink href={href} aria-label="My account">
<IoPersonOutline />
</IconButtonLink>
);
}

View file

@ -0,0 +1,132 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
export type Customer = {
id: string;
email: string;
first_name: string | null;
last_name: string | null;
phone: string | null;
created_at: string;
};
type AuthState = {
customer: Customer | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: {
email: string;
password: string;
first_name: string;
last_name: string;
}) => Promise<void>;
logout: () => Promise<void>;
refreshCustomer: () => Promise<void>;
};
const AuthContext = createContext<AuthState | null>(null);
async function apiJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg = body?.error ?? `Auth API error: ${res.status}`;
throw new Error(msg);
}
return res.json();
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [customer, setCustomer] = useState<Customer | null>(null);
const [isLoading, setIsLoading] = useState(true);
// On mount: check if user is already authenticated via cookie
useEffect(() => {
async function checkAuth() {
try {
const data = await apiJson<{ customer: Customer }>("/api/account/me");
setCustomer(data.customer);
} catch {
setCustomer(null);
} finally {
setIsLoading(false);
}
}
checkAuth();
}, []);
const login = useCallback(async (email: string, password: string) => {
const data = await apiJson<{ customer: Customer }>("/api/account/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
setCustomer(data.customer);
}, []);
const register = useCallback(
async (regData: {
email: string;
password: string;
first_name: string;
last_name: string;
}) => {
const data = await apiJson<{ customer: Customer }>(
"/api/account/register",
{
method: "POST",
body: JSON.stringify(regData),
},
);
setCustomer(data.customer);
},
[],
);
const logout = useCallback(async () => {
await apiJson("/api/account/logout", { method: "POST" });
setCustomer(null);
}, []);
const refreshCustomer = useCallback(async () => {
try {
const data = await apiJson<{ customer: Customer }>("/api/account/me");
setCustomer(data.customer);
} catch {
setCustomer(null);
}
}, []);
const value = useMemo<AuthState>(
() => ({
customer,
isAuthenticated: !!customer,
isLoading,
login,
register,
logout,
refreshCustomer,
}),
[customer, isLoading, login, register, logout, refreshCustomer],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View file

@ -0,0 +1,291 @@
"use client";
import { useState } from "react";
import { useAuth } from "./AuthContext";
import { ArrowButton } from "@/components/ArrowLink";
const inputClass =
"no-ring w-full rounded-xl border border-lightline px-6 py-3 shadow-lg text-lighttext transition-all duration-200 ease-in-out placeholder:text-lightsec hover:border-lightline-hover focus:border-lightline-focus dark:border-darkline dark:text-darktext dark:placeholder:text-darksec dark:hover:border-darkline-hover dark:focus:border-darkline-focus";
const PASSWORD_RULES = [
{ label: "At least 8 characters", test: (pw: string) => pw.length >= 8 },
{ label: "One uppercase letter", test: (pw: string) => /[A-Z]/.test(pw) },
{ label: "One lowercase letter", test: (pw: string) => /[a-z]/.test(pw) },
{ label: "One number", test: (pw: string) => /\d/.test(pw) },
{ label: "One special character (!@#$%^&*\u2026)", test: (pw: string) => /[^A-Za-z0-9]/.test(pw) },
];
type AuthFormProps = {
onSuccess?: () => void;
/** Hide title and left-align the mode toggle link */
compact?: boolean;
};
export function AuthForm({ onSuccess, compact }: AuthFormProps) {
const { login, register } = useAuth();
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const showRules = mode === "register" && password.length > 0;
async function doSubmit() {
setError(null);
if (mode === "register") {
const failing = PASSWORD_RULES.find((r) => !r.test(password));
if (failing) {
setError(failing.label.replace(/^One/, "Must contain at least one").replace(/^At least/, "Must be at least"));
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
}
setSubmitting(true);
try {
if (mode === "login") {
await login(email, password);
} else {
await register({ email, password, first_name: firstName, last_name: lastName });
}
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setSubmitting(false);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
doSubmit();
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
doSubmit();
}
}
return (
<div>
{!compact && (
<h2 className="text-center font-argesta text-3xl">
{mode === "login" ? "Sign In" : "Create Account"}
</h2>
)}
{compact ? (
<div className="flex flex-col gap-4">
{mode === "register" && (
<div className="grid gap-3 sm:grid-cols-2">
<input
type="text"
placeholder="First name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
<input
type="text"
placeholder="Last name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
</div>
)}
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
{showRules && (
<ul className="flex flex-col gap-1 text-xs">
{PASSWORD_RULES.map((rule) => {
const passed = rule.test(password);
return (
<li
key={rule.label}
className={
passed
? "text-green-600 dark:text-green-400"
: "text-lightsec dark:text-darksec"
}
>
{passed ? "\u2713" : "\u2022"} {rule.label}
</li>
);
})}
</ul>
)}
{mode === "register" && (
<input
type="password"
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
)}
{error && (
<div className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
{error}
</div>
)}
<button
type="button"
disabled={submitting}
onClick={() => doSubmit()}
className="w-full rounded-xl bg-trptkblue px-4 py-4 font-silkasb text-sm text-white shadow-lg transition-all duration-200 ease-in-out hover:opacity-90 disabled:pointer-events-none disabled:opacity-50 dark:bg-white dark:text-lighttext"
>
{submitting
? mode === "login"
? "Signing in\u2026"
: "Creating account\u2026"
: mode === "login"
? "Sign In"
: "Create Account"}
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="mt-10 flex flex-col gap-4">
{mode === "register" && (
<div className="grid gap-3 sm:grid-cols-2">
<input
type="text"
required
placeholder="First name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Last name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className={inputClass}
/>
</div>
)}
<input
type="email"
required
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
<input
type="password"
required
minLength={8}
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputClass}
/>
{showRules && (
<ul className="flex flex-col gap-1 text-xs">
{PASSWORD_RULES.map((rule) => {
const passed = rule.test(password);
return (
<li
key={rule.label}
className={
passed
? "text-green-600 dark:text-green-400"
: "text-lightsec dark:text-darksec"
}
>
{passed ? "\u2713" : "\u2022"} {rule.label}
</li>
);
})}
</ul>
)}
{mode === "register" && (
<input
type="password"
required
minLength={8}
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={inputClass}
/>
)}
{error && (
<div className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
{error}
</div>
)}
<button
type="submit"
disabled={submitting}
className="w-full rounded-xl bg-trptkblue px-4 py-4 font-silkasb text-sm text-white shadow-lg transition-all duration-200 ease-in-out hover:opacity-90 disabled:pointer-events-none disabled:opacity-50 dark:bg-white dark:text-lighttext"
>
{submitting
? mode === "login"
? "Signing in\u2026"
: "Creating account\u2026"
: mode === "login"
? "Sign In"
: "Create Account"}
</button>
</form>
)}
<p className={`mt-6 text-sm text-lightsec dark:text-darksec${compact ? "" : " text-center"}`}>
{mode === "login" ? "Don\u2019t have an account?" : "Already have an account?"}{" "}
<ArrowButton
onClick={() => {
setMode(mode === "login" ? "register" : "login");
setError(null);
}}
className="text-trptkblue dark:text-white"
>
{mode === "login" ? "Create one" : "Sign in"}
</ArrowButton>
</p>
</div>
);
}

View file

@ -0,0 +1,69 @@
import Link from "next/link";
import Image from "next/image";
import { urlFor } from "@/lib/sanityImage";
import type { SanityImageSource } from "@sanity/image-url";
export type BlogCardData = {
_id?: string;
title?: string;
subtitle?: string;
slug?: string;
author?: string;
publishDate?: string;
category?: string;
featuredImage?: SanityImageSource;
};
type Props = {
blog: BlogCardData;
className?: string;
};
function formatPublishDate(dateString?: string) {
if (!dateString) return null;
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(dateString));
}
export function BlogCard({ blog, className = "" }: Props) {
const url = blog.slug ? `/blog/${blog.slug}` : "#";
const imageSrc = blog.featuredImage ? urlFor(blog.featuredImage).url() : null;
return (
<Link
href={url}
className={`group transition-color relative z-10 rounded-xl bg-lightbg shadow-lg ring-1 ring-lightline duration-300 ease-in-out hover:text-trptkblue hover:ring-lightline-hover dark:bg-darkbg dark:ring-darkline dark:hover:text-white dark:hover:ring-darkline-hover ${className}`}
>
<div className="relative aspect-square w-full overflow-hidden rounded-xl">
{imageSrc ? (
<Image
src={imageSrc}
alt={blog.title ? `Featured image for ${blog.title}` : "Blog post image"}
fill
className="object-cover"
sizes="(max-width: 560px) calc(100vw - 32px), 450px"
/>
) : (
<div className="absolute inset-0 bg-lightline-mid dark:bg-darkline-mid" />
)}
</div>
<div className="p-4 sm:p-5 sm:pb-15">
<div className="">
<h3 className="mb-2 break-words">{blog.title}</h3>
<h4 className="text-sm break-words text-lightsec dark:text-darksec">
{blog.subtitle || blog.category}
</h4>
</div>
</div>
<div className="absolute right-4 bottom-4 left-4 hidden text-lightsec opacity-50 sm:right-5 sm:bottom-5 sm:left-5 sm:flex dark:text-darksec">
<span className="text-sm">{formatPublishDate(blog.publishDate)}</span>
</div>
</Link>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import { useRef } from "react";
import { motion } from "framer-motion";
import { IoCartOutline } from "react-icons/io5";
import { IconButton } from "@/components/IconButton";
import { useCart } from "./CartContext";
export function CartButton({ className }: { className?: string }) {
const { itemCount, setDrawerOpen } = useCart();
const hasItems = itemCount > 0;
// If the component mounts with items already in the cart (e.g. navigating
// between pages), start fully visible so there's no fade-in on every page.
const mountedWithItems = useRef(hasItems);
return (
<motion.div
className={className}
initial={{
opacity: mountedWithItems.current ? 1 : 0,
display: mountedWithItems.current ? "block" : "none",
}}
animate={
hasItems
? {
opacity: 1,
display: "block",
transition: {
type: "tween",
ease: "easeInOut",
duration: 0.2,
delay: 0.4,
},
}
: {
opacity: 0,
transition: {
type: "tween",
ease: "easeInOut",
duration: 0.2,
},
transitionEnd: {
display: "none",
},
}
}
style={{ pointerEvents: hasItems ? "auto" : "none" }}
>
<IconButton onClick={() => setDrawerOpen(true)} aria-label="Open cart" className="text-lg">
<span className="relative">
<IoCartOutline />
<span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-trptkblue text-xs leading-none font-bold text-white dark:bg-white dark:text-lighttext">
{itemCount}
</span>
</span>
</IconButton>
</motion.div>
);
}

View file

@ -0,0 +1,195 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { MedusaCart } from "@/lib/medusa";
const CART_ID_KEY = "trptk_cart_id";
async function apiJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg = body?.error ?? `Cart API error: ${res.status}`;
console.error("[cart]", msg);
throw new Error(msg);
}
return res.json();
}
type CartState = {
cart: MedusaCart | null;
isLoading: boolean;
isAdding: boolean;
itemCount: number;
drawerOpen: boolean;
setDrawerOpen: (open: boolean) => void;
addItem: (variantId: string, quantity?: number) => Promise<void>;
removeItem: (lineItemId: string) => Promise<void>;
updateItem: (lineItemId: string, quantity: number) => Promise<void>;
applyPromo: (code: string) => Promise<void>;
removePromo: (code: string) => Promise<void>;
resetCart: () => void;
};
const CartContext = createContext<CartState | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [cart, setCart] = useState<MedusaCart | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isAdding, setIsAdding] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
// Restore cart from localStorage on mount
useEffect(() => {
async function restore() {
const cartId = localStorage.getItem(CART_ID_KEY);
if (cartId) {
try {
const existing = await apiJson<MedusaCart>(`/api/cart?id=${cartId}`);
setCart(existing);
} catch {
localStorage.removeItem(CART_ID_KEY);
}
}
setIsLoading(false);
}
restore();
}, []);
const ensureCart = useCallback(async (): Promise<string> => {
if (cart?.id) return cart.id;
const newCart = await apiJson<MedusaCart>("/api/cart", { method: "POST" });
localStorage.setItem(CART_ID_KEY, newCart.id);
setCart(newCart);
return newCart.id;
}, [cart?.id]);
const addItem = useCallback(
async (variantId: string, quantity = 1) => {
setIsAdding(true);
try {
const cartId = await ensureCart();
const updated = await apiJson<MedusaCart>(`/api/cart/${cartId}/items`, {
method: "POST",
body: JSON.stringify({ variant_id: variantId, quantity }),
});
setCart(updated);
setDrawerOpen(true);
} finally {
setIsAdding(false);
}
},
[ensureCart],
);
const removeItem = useCallback(
async (lineItemId: string) => {
if (!cart?.id) return;
const updated = await apiJson<MedusaCart>(`/api/cart/${cart.id}/items/${lineItemId}`, {
method: "DELETE",
});
setCart(updated);
},
[cart?.id],
);
const updateItem = useCallback(
async (lineItemId: string, quantity: number) => {
if (!cart?.id) return;
if (quantity <= 0) {
await removeItem(lineItemId);
return;
}
const updated = await apiJson<MedusaCart>(`/api/cart/${cart.id}/items/${lineItemId}`, {
method: "POST",
body: JSON.stringify({ quantity }),
});
setCart(updated);
},
[cart?.id, removeItem],
);
const applyPromo = useCallback(
async (code: string) => {
if (!cart?.id) return;
const updated = await apiJson<MedusaCart>(`/api/cart/${cart.id}/promotions`, {
method: "POST",
body: JSON.stringify({ code }),
});
setCart(updated);
},
[cart?.id],
);
const removePromo = useCallback(
async (code: string) => {
if (!cart?.id) return;
const updated = await apiJson<MedusaCart>(`/api/cart/${cart.id}/promotions`, {
method: "DELETE",
body: JSON.stringify({ code }),
});
setCart(updated);
},
[cart?.id],
);
const resetCart = useCallback(() => {
setCart(null);
localStorage.removeItem(CART_ID_KEY);
}, []);
const itemCount = useMemo(
() => cart?.items?.reduce((sum, item) => sum + item.quantity, 0) ?? 0,
[cart?.items],
);
const value = useMemo<CartState>(
() => ({
cart,
isLoading,
isAdding,
itemCount,
drawerOpen,
setDrawerOpen,
addItem,
removeItem,
updateItem,
applyPromo,
removePromo,
resetCart,
}),
[
cart,
isLoading,
isAdding,
itemCount,
drawerOpen,
setDrawerOpen,
addItem,
removeItem,
updateItem,
applyPromo,
removePromo,
resetCart,
],
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used within CartProvider");
return ctx;
}

Some files were not shown because too many files have changed in this diff Show more