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