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

288 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}