162 lines
5.7 KiB
TypeScript
162 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>
|
||
|
|
);
|
||
|
|
}
|