289 lines
15 KiB
TypeScript
289 lines
15 KiB
TypeScript
|
|
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 & 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'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'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'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'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>
|
|||
|
|
);
|
|||
|
|
}
|