Initial commit
This commit is contained in:
commit
2409fecfef
154 changed files with 34572 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
.next
|
||||
.env*
|
||||
.git
|
||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal 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
5
.prettierrc
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "./app/globals.css"
|
||||
}
|
||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{
|
||||
}
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal 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
12
app/[slug]/layout.tsx
Normal 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
135
app/[slug]/page.tsx
Normal 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
18
app/account/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
app/account/login/page.tsx
Normal file
32
app/account/login/page.tsx
Normal 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
48
app/account/page.tsx
Normal 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
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
BIN
app/android-chrome-512x512.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
75
app/api/account/addresses/[id]/route.ts
Normal file
75
app/api/account/addresses/[id]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
60
app/api/account/addresses/route.ts
Normal file
60
app/api/account/addresses/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
221
app/api/account/downloads/route.ts
Normal file
221
app/api/account/downloads/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
99
app/api/account/login/route.ts
Normal file
99
app/api/account/login/route.ts
Normal 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 });
|
||||
}
|
||||
12
app/api/account/logout/route.ts
Normal file
12
app/api/account/logout/route.ts
Normal 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 });
|
||||
}
|
||||
20
app/api/account/me/route.ts
Normal file
20
app/api/account/me/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
22
app/api/account/orders/route.ts
Normal file
22
app/api/account/orders/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
176
app/api/account/register/route.ts
Normal file
176
app/api/account/register/route.ts
Normal 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 });
|
||||
}
|
||||
55
app/api/cart/[cartId]/items/[itemId]/route.ts
Normal file
55
app/api/cart/[cartId]/items/[itemId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
42
app/api/cart/[cartId]/items/route.ts
Normal file
42
app/api/cart/[cartId]/items/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
70
app/api/cart/[cartId]/promotions/route.ts
Normal file
70
app/api/cart/[cartId]/promotions/route.ts
Normal 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
58
app/api/cart/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
32
app/api/checkout/complete/route.ts
Normal file
32
app/api/checkout/complete/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
47
app/api/checkout/downloads/route.ts
Normal file
47
app/api/checkout/downloads/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
app/api/checkout/payment/route.ts
Normal file
46
app/api/checkout/payment/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
29
app/api/checkout/shipping-method/route.ts
Normal file
29
app/api/checkout/shipping-method/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
22
app/api/checkout/shipping-options/route.ts
Normal file
22
app/api/checkout/shipping-options/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
53
app/api/checkout/update/route.ts
Normal file
53
app/api/checkout/update/route.ts
Normal 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
109
app/api/search/route.ts
Normal 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
BIN
app/apple-touch-icon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
12
app/artist/[slug]/layout.tsx
Normal file
12
app/artist/[slug]/layout.tsx
Normal 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
203
app/artist/[slug]/page.tsx
Normal 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
19
app/artists/error.tsx
Normal 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'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
12
app/artists/layout.tsx
Normal 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
153
app/artists/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/blog/(archive)/error.tsx
Normal file
19
app/blog/(archive)/error.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
12
app/blog/(archive)/layout.tsx
Normal file
12
app/blog/(archive)/layout.tsx
Normal 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
175
app/blog/(archive)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
app/blog/[slug]/layout.tsx
Normal file
12
app/blog/[slug]/layout.tsx
Normal 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
305
app/blog/[slug]/page.tsx
Normal 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
18
app/checkout/layout.tsx
Normal 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
806
app/checkout/page.tsx
Normal 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 & 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)} × {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">
|
||||
−{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}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
248
app/checkout/return/page.tsx
Normal file
248
app/checkout/return/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
12
app/composer/[slug]/layout.tsx
Normal file
12
app/composer/[slug]/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
214
app/composer/[slug]/page.tsx
Normal file
214
app/composer/[slug]/page.tsx
Normal 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
19
app/composers/error.tsx
Normal 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'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
12
app/composers/layout.tsx
Normal 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
180
app/composers/page.tsx
Normal 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
19
app/concerts/error.tsx
Normal 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'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
12
app/concerts/layout.tsx
Normal 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
115
app/concerts/page.tsx
Normal 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
BIN
app/favicon-16x16.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 395 B |
BIN
app/favicon-32x32.png
Executable file
BIN
app/favicon-32x32.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
BIN
app/favicon.ico
Executable file
BIN
app/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
106
app/globals.css
Normal file
106
app/globals.css
Normal 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
102
app/layout.tsx
Normal 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
440
app/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
app/release/[slug]/booklet/route.ts
Normal file
50
app/release/[slug]/booklet/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
11
app/release/[slug]/layout.tsx
Normal file
11
app/release/[slug]/layout.tsx
Normal 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
502
app/release/[slug]/page.tsx
Normal 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">
|
||||
“{review.quote}”
|
||||
</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
19
app/releases/error.tsx
Normal 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'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
12
app/releases/layout.tsx
Normal 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
259
app/releases/page.tsx
Normal 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
25
app/robots.ts
Normal 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
1
app/site.webmanifest
Executable 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
68
app/sitemap.ts
Normal 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];
|
||||
}
|
||||
12
app/work/[slug]/layout.tsx
Normal file
12
app/work/[slug]/layout.tsx
Normal 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
217
app/work/[slug]/page.tsx
Normal 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} />;
|
||||
}
|
||||
43
components/AnimatedText.tsx
Normal file
43
components/AnimatedText.tsx
Normal 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
46
components/ArrowLink.tsx
Normal 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
43
components/Breadcrumb.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
components/CountrySelect.tsx
Normal file
116
components/CountrySelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
components/FilterDropdown.tsx
Normal file
142
components/FilterDropdown.tsx
Normal 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
47
components/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
components/IconButtonMini.tsx
Normal file
46
components/IconButtonMini.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
components/PaginationNav.tsx
Normal file
72
components/PaginationNav.tsx
Normal 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
178
components/SearchBar.tsx
Normal 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
130
components/SortDropdown.tsx
Normal 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
103
components/TabsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
components/account/AccountTabs.tsx
Normal file
28
components/account/AccountTabs.tsx
Normal 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} />;
|
||||
}
|
||||
277
components/account/AddressesTab.tsx
Normal file
277
components/account/AddressesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
components/account/DownloadCard.tsx
Normal file
142
components/account/DownloadCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
components/account/DownloadsTab.tsx
Normal file
379
components/account/DownloadsTab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
274
components/account/OrdersTab.tsx
Normal file
274
components/account/OrdersTab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
14
components/account/ProfileTab.tsx
Normal file
14
components/account/ProfileTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
components/artist/ArtistCard.tsx
Normal file
76
components/artist/ArtistCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
components/artist/ArtistCardCompact.tsx
Normal file
51
components/artist/ArtistCardCompact.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
components/artist/ArtistConcertsTab.tsx
Normal file
25
components/artist/ArtistConcertsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
265
components/artist/ArtistReleasesTab.tsx
Normal file
265
components/artist/ArtistReleasesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
components/artist/ArtistTabs.tsx
Normal file
54
components/artist/ArtistTabs.tsx
Normal 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} />;
|
||||
}
|
||||
95
components/artist/ArtistWorksTab.tsx
Normal file
95
components/artist/ArtistWorksTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
components/artist/types.ts
Normal file
25
components/artist/types.ts
Normal 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 "";
|
||||
}
|
||||
19
components/auth/AccountButton.tsx
Normal file
19
components/auth/AccountButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
components/auth/AuthContext.tsx
Normal file
132
components/auth/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
291
components/auth/AuthForm.tsx
Normal file
291
components/auth/AuthForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
components/blog/BlogCard.tsx
Normal file
69
components/blog/BlogCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
components/cart/CartButton.tsx
Normal file
60
components/cart/CartButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
components/cart/CartContext.tsx
Normal file
195
components/cart/CartContext.tsx
Normal 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
Loading…
Reference in a new issue