planmymeal/app/recipes/[id]/page.tsx
Cayden Schwechten c19b910c36 init
2026-10-31 20:44:29 -05:00

161 lines
5.7 KiB
TypeScript

import { ArrowLeft, BookHeart } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense, ViewTransition } from "react";
import { AddToPlanControl } from "@/app/components/AddToPlanControl";
import { RecipeMeta } from "@/app/components/RecipeDetail";
import { RecipeServings } from "@/app/components/RecipeServings";
import { SiteHeader } from "@/app/components/SiteHeader";
import { PANTRY_STAPLE_IDS } from "@/app/lib/catalog";
import { buildGroceryList } from "@/app/lib/grocery";
import { CUISINES } from "@/app/lib/plan";
import { RECIPES, RECIPES_BY_ID } from "@/app/lib/recipes";
import { formatUsd } from "@/app/lib/units";
export function generateStaticParams() {
return RECIPES.map((r) => ({ id: r.id }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const recipe = RECIPES_BY_ID.get((await params).id);
return recipe
? {
title: recipe.title,
description: recipe.description,
}
: {};
}
/**
* URL-independent App Shell renders instantly; everything reading `params`
* lives inside <Suspense> so Next 16 Partial Prefetching never blocks navigation.
*/
export default function RecipePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<div className="min-h-dvh">
<SiteHeader active="recipes" />
<main className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
<Suspense fallback={<RecipeSkeleton />}>
<RecipeContent params={params} />
</Suspense>
</main>
</div>
);
}
async function RecipeContent({ params }: { params: Promise<{ id: string }> }) {
const recipe = RECIPES_BY_ID.get((await params).id);
if (!recipe) notFound();
const cost = buildGroceryList([recipe.id], {
householdSize: recipe.servings,
onHandItems: PANTRY_STAPLE_IDS,
});
const cuisineLabel = CUISINES.find((c) => c.id === recipe.cuisine)?.label;
const isFamily = recipe.styles.includes("caydens_corner");
const related = RECIPES.filter(
(r) =>
r.id !== recipe.id &&
r.kind === recipe.kind &&
(isFamily
? r.styles.includes("caydens_corner")
: r.cuisine === recipe.cuisine && !r.styles.includes("caydens_corner")),
).slice(0, 4);
return (
<>
<Link
href={isFamily ? "/caydens-corner" : "/recipes"}
className="inline-flex items-center gap-1.5 text-sm font-medium text-ink-muted transition-colors hover:text-brand-300"
>
<ArrowLeft className="size-4" aria-hidden />
{isFamily ? "Cayden's Corner" : "All recipes"}
</Link>
{isFamily && (
<p className="mt-4 inline-flex items-center gap-1.5 font-mono text-xs font-semibold tracking-wider text-brand-300 uppercase">
<BookHeart className="size-3.5" strokeWidth={2} aria-hidden />
Heirloom Family Recipe
</p>
)}
<ViewTransition name={`recipe-title-${recipe.id}`}>
<h1 className="mt-3 font-serif text-3xl font-semibold tracking-tight text-balance sm:text-4xl lg:text-5xl">
{recipe.title}
</h1>
</ViewTransition>
<p className="mt-3 max-w-2xl text-lg leading-relaxed text-ink-muted">{recipe.description}</p>
<div className="mt-4">
<RecipeMeta recipe={recipe} />
</div>
{/* Cost ledger + Add to Plan action bar */}
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 rounded-xl border border-line bg-surface p-4 sm:px-5">
<dl className="flex flex-wrap items-center gap-6 sm:gap-10">
<div>
<dt className="text-xs text-ink-faint">Cost eaten / serving</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold text-brand-300 tabular-nums">
{formatUsd(cost.usedTotal / recipe.servings)}
</dd>
</div>
<div className="border-l border-line pl-6 sm:pl-10">
<dt className="text-xs text-ink-faint">Full packages new</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold text-ink tabular-nums">
{formatUsd(cost.checkoutTotal)}
</dd>
</div>
</dl>
<AddToPlanControl recipeId={recipe.id} recipeTitle={recipe.title} />
</div>
<div className="mt-8 rounded-xl border border-line bg-surface p-5 sm:p-8">
<RecipeServings recipe={recipe} />
</div>
{related.length > 0 && (
<section className="mt-12 border-t border-line pt-8">
<h2 className="font-serif text-xl font-semibold">
More {isFamily ? "from Cayden's Corner" : cuisineLabel}
</h2>
<ul className="mt-4 grid gap-3.5 sm:grid-cols-2">
{related.map((r) => (
<li key={r.id}>
<Link
href={`/recipes/${r.id}`}
className="group block rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
>
<span className="font-serif text-lg font-semibold text-ink group-hover:text-brand-300">{r.title}</span>
<span className="mt-1 block text-sm text-ink-muted">{r.description}</span>
</Link>
</li>
))}
</ul>
</section>
)}
</>
);
}
function RecipeSkeleton() {
return (
<div className="animate-pulse space-y-4" aria-busy="true" aria-label="Loading recipe">
<div className="h-4 w-28 rounded bg-raised" />
<div className="h-11 w-2/3 rounded bg-raised" />
<div className="h-5 w-1/2 rounded bg-raised" />
<div className="mt-6 h-20 rounded-xl bg-raised" />
<div className="mt-8 h-96 rounded-xl bg-raised" />
</div>
);
}