planmymeal/app/page.tsx

289 lines
15 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
import { ArrowRight, BookHeart, Flame, ReceiptText, SlidersHorizontal } from "lucide-react";
import Link from "next/link";
import { OverlapExplorer, type OverlapComboSnapshot, type OverlapDish } from "./components/OverlapExplorer";
import { RotatingPreview, type PreviewSet } from "./components/RotatingPreview";
import { Logo, SiteHeader } from "./components/SiteHeader";
import { CATALOG, PANTRY_STAPLE_IDS } from "./lib/catalog";
import { buildGroceryList } from "./lib/grocery";
import { APPLIANCES, CUISINES } from "./lib/plan";
import { RECIPES } from "./lib/recipes";
import { formatUsd } from "./lib/units";
// ---------------------------------------------------------------------------
// Landing page (server component, fully static).
//
// Every statistic, menu preview, and interactive overlap combination is
// derived from the real recipe & store catalog at build time so zero catalog
// payload needs to ship to the landing-page client bundle.
// ---------------------------------------------------------------------------
const DEMO_RECIPE_IDS = ["chicken-curry", "red-lentil-dal", "chana-masala", "garlic-butter-shrimp-and-rice"] as const;
const OVERLAP_DATA = (() => {
const options = { householdSize: 2, onHandItems: PANTRY_STAPLE_IDS };
const dishes: OverlapDish[] = DEMO_RECIPE_IDS.map((id) => {
const r = RECIPES.find((x) => x.id === id)!;
const cuisineLabel = CUISINES.find((c) => c.id === r.cuisine)?.label ?? r.cuisine;
return {
id,
title: r.title,
cuisine: `${cuisineLabel} · ${r.prepTime} min`,
standaloneCheckout: buildGroceryList([id], options).checkoutTotal,
};
});
const combos: Record<string, OverlapComboSnapshot> = {};
const totalMasks = 1 << DEMO_RECIPE_IDS.length;
for (let mask = 1; mask < totalMasks; mask++) {
const picked = DEMO_RECIPE_IDS.filter((_, idx) => (mask & (1 << idx)) !== 0);
const key = [...picked].sort().join(",");
const summary = buildGroceryList(picked, options);
combos[key] = {
checkoutTotal: summary.checkoutTotal,
usedTotal: summary.usedTotal,
carryoverValue: summary.carryoverValue,
sharedNames: summary.lines.filter((l) => l.usedBy.length > 1 && !l.onHand).map((l) => l.item.name),
packageCount: summary.lines.length,
};
}
return { dishes, combos };
})();
const PREVIEW_THEMES: { theme: string; blurb: string; meals: [slot: string, recipeId: string][] }[] = [
{ theme: "Comfort classics", blurb: "Cozy & thrifty", meals: [["Mon · Dinner", "chicken-pot-pie"], ["Tue · Lunch", "chicken-noodle-soup"], ["Tue · Dinner", "beef-stroganoff"]] },
{ theme: "Shared spice rack", blurb: "High overlap", meals: [["Mon · Dinner", "chicken-curry"], ["Tue · Lunch", "red-lentil-dal"], ["Tue · Dinner", "pad-thai"]] },
{ theme: "Weeknight seafood", blurb: "Budget catch", meals: [["Mon · Dinner", "garlic-butter-shrimp-and-rice"], ["Tue · Lunch", "tuna-chickpea-pitas"], ["Tue · Dinner", "baja-fish-tacos"]] },
{ theme: "Dorm & studio kitchen", blurb: "Microwave + rice cooker", meals: [["Mon · Breakfast", "berry-banana-smoothie"], ["Mon · Lunch", "microwave-buffalo-chicken-rice-bowls"], ["Mon · Dinner", "rice-cooker-arroz-con-pollo"]] },
{ theme: "Plant-forward week", blurb: "Meatless & filling", meals: [["Mon · Breakfast", "shakshuka"], ["Mon · Lunch", "chana-masala"], ["Mon · Dinner", "black-bean-enchiladas"]] },
{ theme: "Sunday batch prep", blurb: "Cook once, eat well", meals: [["Breakfasts", "baked-oatmeal-squares"], ["Lunches", "slow-cooker-salsa-chicken"], ["Dinners", "chicken-burrito-bowls"]] },
{ theme: "Korean pantry week", blurb: "One tub of gochujang", meals: [["Mon · Dinner", "bulgogi-beef-bowls"], ["Tue · Lunch", "kimchi-fried-rice"], ["Tue · Dinner", "dakgalbi-spicy-chicken-stir-fry"]] },
];
const PREVIEW_SETS: PreviewSet[] = PREVIEW_THEMES.map(({ theme, blurb, meals }) => {
const options = { householdSize: 2, onHandItems: PANTRY_STAPLE_IDS };
const recipe = (id: string) => {
const found = RECIPES.find((r) => r.id === id);
if (!found) throw new Error(`Landing preview: unknown recipe id "${id}"`);
return found;
};
const cart = buildGroceryList(meals.map(([, id]) => id), options);
return {
theme,
blurb,
meals: meals.map(([slot, id]) => ({ slot, title: recipe(id).title, cost: formatUsd(buildGroceryList([id], options).usedTotal) })),
total: formatUsd(cart.checkoutTotal),
sharedItems: cart.sharedItemCount,
itemCount: cart.lines.length,
};
});
const STEPS = [
{
num: "01",
title: "Set your register target",
body: "Choose a weekly grocery ceiling, 1–7 days, meals per day, and household size. Ingredient quantities scale automatically.",
},
{
num: "02",
title: "Match your real kitchen",
body: "Toggle only the appliances you own—stovetop, air fryer, rice cooker, microwave. Exclude allergens or mark items already on your shelf.",
},
{
num: "03",
title: "Pack the week with overlap",
body: "Auto-fill prioritizes dishes that share open packages of produce, dairy, and aromatics—or pick slot-by-slot sorted by marginal cart cost.",
},
{
num: "04",
title: "Walk the aisles once",
body: "Export one consolidated list grouped by store aisle, priced in whole packages the way checkout actually charges.",
},
];
const FAMILY_COUNT = RECIPES.filter((r) => r.styles.includes("caydens_corner")).length;
export default function LandingPage() {
return (
<div className="flex min-h-dvh flex-col">
<SiteHeader active="home" />
<main className="flex-1">
{/* --- Editorial Hero ------------------------------------------------ */}
<section className="paper-grain border-b border-line">
<div className="mx-auto grid max-w-7xl items-center gap-12 px-4 py-14 sm:px-6 lg:grid-cols-[1.1fr_0.9fr] lg:py-22">
<div>
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">
Weekly Meal &amp; Grocery Ledger · Saved Locally
</p>
<h1 className="mt-3 font-serif text-4xl leading-[1.08] font-semibold tracking-tight text-balance sm:text-5xl lg:text-[3.4rem]">
Cook a full week around{" "}
<span className="italic text-brand-300">what groceries actually cost at checkout.</span>
</h1>
<p className="mt-5 max-w-xl text-base leading-relaxed text-pretty text-ink-muted sm:text-lg">
Most meal planners divide a pinch of cumin into imaginary pennies. Plan my Meals prices whole store packages,
pairs recipes that share ingredients, and filters strictly to the appliances on your counter.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link
href="/plan"
className="trim-cap inline-flex min-h-12 items-center gap-2 rounded-lg bg-brand-600 px-6 py-3 font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500"
>
Start this week&apos;s plan
<ArrowRight className="size-4" strokeWidth={2} aria-hidden />
</Link>
<Link
href="/recipes"
className="trim-cap inline-flex min-h-12 items-center gap-2 rounded-lg border border-line-strong bg-surface px-5 py-3 font-medium text-ink transition-colors hover:bg-raised"
>
Browse {RECIPES.length} recipes
</Link>
</div>
{/* Architectural Ledger Metrics */}
<dl className="mt-10 grid max-w-xl grid-cols-2 gap-x-6 gap-y-4 border-t border-line pt-6 sm:grid-cols-4">
<div>
<dt className="text-xs text-ink-faint">Tested recipes</dt>
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{RECIPES.length}</dd>
</div>
<div>
<dt className="text-xs text-ink-faint">Global cuisines</dt>
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{CUISINES.length}</dd>
</div>
<div>
<dt className="text-xs text-ink-faint">Appliances</dt>
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{APPLIANCES.length}</dd>
</div>
<div>
<dt className="text-xs text-ink-faint">Priced items</dt>
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{CATALOG.length}</dd>
</div>
</dl>
</div>
<RotatingPreview sets={PREVIEW_SETS} />
</div>
</section>
{/* --- Interactive Package Overlap Proof ---------------------------- */}
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22">
<div className="mb-8 max-w-2xl">
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">Why the math matters</p>
<h2 className="mt-2 font-serif text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Built for the checkout lane, not fictional per-gram math
</h2>
</div>
<OverlapExplorer dishes={OVERLAP_DATA.dishes} combos={OVERLAP_DATA.combos} />
{/* Three concise architectural pillars */}
<div className="mt-8 grid gap-px overflow-hidden rounded-xl border border-line bg-line lg:grid-cols-3">
<div className="bg-surface p-6">
<ReceiptText className="size-5 text-brand-300" strokeWidth={1.75} aria-hidden />
<h3 className="mt-3 font-serif text-lg font-semibold">Whole-package register totals</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-muted">
You can&apos;t buy 0.3 lb of rice. Every ingredient rounds up to real store packages, showing both what you pay
today and what carries over into next week&apos;s pantry.
</p>
</div>
<div className="bg-surface p-6">
<Flame className="size-5 text-brand-300" strokeWidth={1.75} aria-hidden />
<h3 className="mt-3 font-serif text-lg font-semibold">Appliance-honest filtering</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-muted">
Cooking in a dorm or studio without an oven? Uncheck it once and every suggested recipe is guaranteed to work on
the gear you actually own.
</p>
</div>
<div className="bg-surface p-6">
<SlidersHorizontal className="size-5 text-brand-300" strokeWidth={1.75} aria-hidden />
<h3 className="mt-3 font-serif text-lg font-semibold">Three-way pantry control</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-muted">
Mark any store item as <strong className="font-medium text-ink">Have it</strong> ($0 at checkout),{" "}
<strong className="font-medium text-ink">Use</strong>, or <strong className="font-medium text-ink">Avoid</strong>{" "}
across an entire aisle like Seafood or Dairy.
</p>
</div>
</div>
</section>
{/* --- Exposed-Border Workflow Ledger ------------------------------- */}
<section id="how-it-works" className="border-y border-line bg-surface">
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<div className="max-w-2xl">
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">How it works</p>
<h2 className="mt-2 font-serif text-3xl font-semibold tracking-tight text-balance">
Four steps from weekly budget to aisle-sorted list
</h2>
</div>
<ol className="mt-10 grid gap-8 border-t border-line pt-8 sm:grid-cols-2 lg:grid-cols-4">
{STEPS.map((step) => (
<li key={step.num} className="flex flex-col">
<span className="font-mono text-xs font-semibold tracking-widest text-brand-300">{step.num}</span>
<h3 className="mt-2 font-serif text-xl font-semibold">{step.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-muted">{step.body}</p>
</li>
))}
</ol>
</div>
</section>
{/* --- Editorial Cuisines & Cayden's Corner Spotlight --------------- */}
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22">
<div className="grid gap-10 lg:grid-cols-[1.2fr_0.8fr] lg:items-center">
<div>
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">On the menu</p>
<h2 className="mt-2 font-serif text-3xl font-semibold tracking-tight text-balance">
{CUISINES.length} cuisines, from quick weeknight bowls to slow Sunday braises
</h2>
<ul className="mt-6 flex flex-wrap gap-2">
{CUISINES.map((c) => (
<li key={c.id} className="rounded-full border border-line-strong bg-surface px-3.5 py-1.5 text-sm text-ink">
{c.label}
</li>
))}
</ul>
<div className="mt-6">
<Link
href="/recipes"
className="inline-flex items-center gap-1.5 font-medium text-brand-300 hover:underline"
>
Explore the full {RECIPES.length}-recipe index
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</div>
{/* Heirloom Collection Card */}
<div className="paper-grain rounded-xl border border-brand-border bg-brand-950 p-6 sm:p-8">
<div className="inline-flex items-center gap-2 font-mono text-xs font-semibold tracking-wider text-brand-300 uppercase">
<BookHeart className="size-4" strokeWidth={1.75} aria-hidden />
Heirloom Collection
</div>
<h3 className="mt-2 font-serif text-2xl font-semibold tracking-tight">Cayden&apos;s Corner</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-muted">
{FAMILY_COUNT} tried-and-true homemade family recipes—holiday sides, comfort mains, and classic bakes—scaled
automatically to your table and integrated right into the weekly planner.
</p>
<Link
href="/caydens-corner"
className="trim-cap mt-5 inline-flex min-h-10 items-center gap-2 rounded-lg border border-line-strong bg-surface px-4 py-2 text-sm font-semibold text-ink transition-colors hover:bg-raised"
>
Open family cookbook
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</div>
</section>
</main>
<footer className="border-t border-line bg-surface">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-6 text-xs text-ink-faint sm:px-6">
<Logo small />
<p>Estimates reflect average grocery prices as of October 2026 and vary by store and region.</p>
</div>
</footer>
</div>
);
}