init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
161
app/recipes/[id]/page.tsx
Normal file
161
app/recipes/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue