Move UI to BoardUI with slate + salmon theme; add PDF plan export
- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button, SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's color aliases onto BoardUI tokens and swap lucide for Remix icons - Retint BoardUI's neutral ramp to cool slate with a salmon accent; drop the dotted background; raise muted text contrast - Simplify the landing page and remove the overlap explorer - Planner: explicit "I have this" toggle on grocery lines, pantry panel above the menu with the staples list spelled out, sticky aisle header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded) - Recipes: replace the family cabbage soup with Cade's Cabbage Soup (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup - Remove unused BoardUI components, dependencies and exports Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b02c7a024b
commit
df5dd3a70e
56 changed files with 4749 additions and 788 deletions
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, CalendarPlus, Check, X } from "lucide-react";
|
||||
import { RiArrowRightLine as ArrowRight, RiCalendarCheckLine as CalendarPlus, RiCheckLine as Check, RiCloseLine as X } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { DAY_NAMES, mealLabel, slotKey, visibleMeals } from "@/app/lib/plan";
|
||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||
import { DEFAULT_STATE, sanitizeState } from "@/app/lib/sanitize";
|
||||
import type { MealPlanState } from "@/app/types";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
|
||||
const STORAGE_KEY = "budget-meal-planner:v2";
|
||||
|
||||
|
|
@ -56,17 +57,15 @@ export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string;
|
|||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
leadingIcon={CalendarPlus}
|
||||
onClick={() => {
|
||||
setPlan(readPlan());
|
||||
setOpen(true);
|
||||
}}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500"
|
||||
>
|
||||
<CalendarPlus className="size-4" strokeWidth={2} aria-hidden />
|
||||
Add to meal plan
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{addedLabel && (
|
||||
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-status-ok-line bg-status-ok-bg px-3 py-1.5 text-xs font-medium text-status-ok">
|
||||
|
|
@ -84,7 +83,7 @@ export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string;
|
|||
onClose={() => setOpen(false)}
|
||||
onClick={(e) => e.target === dialogRef.current && setOpen(false)}
|
||||
aria-labelledby="add-to-plan-title"
|
||||
className="m-0 mt-auto max-h-[85dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-2xl"
|
||||
className="m-0 mt-auto max-h-[85dvh] w-full max-w-none overflow-y-auto rounded-t-3xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-3xl"
|
||||
>
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-line pb-3">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { Meter, type MeterTone } from "@/components/base/meter/meter";
|
||||
import { formatUsd } from "@/app/lib/units";
|
||||
|
||||
/** Herb sage under 85%, saffron amber up to 100%, chili red over budget. */
|
||||
function budgetTone(spent: number, budget: number) {
|
||||
/** Success under 85%, warning up to 100%, error over budget. */
|
||||
function budgetTone(spent: number, budget: number): { tone: MeterTone; text: string; label: string } {
|
||||
const ratio = budget > 0 ? spent / budget : 0;
|
||||
if (ratio > 1) return { bar: "bg-status-danger", text: "text-status-danger", label: "Over budget" };
|
||||
if (ratio > 0.85) return { bar: "bg-status-warn", text: "text-status-warn", label: "Close to budget" };
|
||||
return { bar: "bg-status-ok", text: "text-status-ok", label: "Under budget" };
|
||||
if (ratio > 1) return { tone: "error", text: "text-status-danger", label: "Over budget" };
|
||||
if (ratio > 0.85) return { tone: "warning", text: "text-status-warn", label: "Close to budget" };
|
||||
return { tone: "success", text: "text-status-ok", label: "Under budget" };
|
||||
}
|
||||
|
||||
interface BudgetMeterProps {
|
||||
|
|
@ -16,25 +17,22 @@ interface BudgetMeterProps {
|
|||
|
||||
export function BudgetMeter({ spent, budget, compact = false }: BudgetMeterProps) {
|
||||
const tone = budgetTone(spent, budget);
|
||||
const percent = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className={compact ? "w-full" : "w-full min-w-48"}>
|
||||
<div className="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span className={`font-mono text-sm font-semibold tabular-nums ${tone.text}`}>{formatUsd(spent)}</span>
|
||||
<span className="font-mono text-ink-muted tabular-nums">/ {formatUsd(budget)}</span>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className={`text-body-medium tabular-nums ${tone.text}`}>{formatUsd(spent)}</span>
|
||||
<span className="text-caption-1-medium text-text-secondary tabular-nums">/ {formatUsd(budget)}</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 h-2 w-full overflow-hidden rounded-full border border-line-strong/40 bg-raised"
|
||||
role="meter"
|
||||
<Meter
|
||||
className="mt-1.5"
|
||||
aria-label="Budget used"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={budget}
|
||||
aria-valuenow={Math.round(spent * 100) / 100}
|
||||
aria-valuetext={`${formatUsd(spent)} of ${formatUsd(budget)}, ${tone.label.toLowerCase()}`}
|
||||
>
|
||||
<div className={`h-full rounded-full transition-[width] duration-300 ${tone.bar}`} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
tone={tone.tone}
|
||||
minValue={0}
|
||||
maxValue={budget > 0 ? budget : 1}
|
||||
value={Math.min(spent, budget > 0 ? budget : 0)}
|
||||
valueLabel={`${formatUsd(spent)} of ${formatUsd(budget)}, ${tone.label.toLowerCase()}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Check } from "lucide-react";
|
||||
import { RiCheckLine } from "@remixicon/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
// A pill-shaped on/off toggle button for filters (cuisines, styles, appliances…).
|
||||
// Includes a visible Check icon when pressed so state never relies on color alone.
|
||||
// A pill-shaped on/off filter toggle on BoardUI tokens.
|
||||
// Shows a check when pressed so state never relies on color alone.
|
||||
|
||||
interface ChipProps {
|
||||
pressed: boolean;
|
||||
|
|
@ -14,24 +15,25 @@ interface ChipProps {
|
|||
dashed?: boolean;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: "min-h-8 px-2.5 py-1 text-xs gap-1.5",
|
||||
md: "min-h-9 px-3.5 py-1.5 text-sm gap-1.5",
|
||||
};
|
||||
|
||||
export function Chip({ pressed, onClick, children, size = "md", dashed = false }: ChipProps) {
|
||||
const look = pressed
|
||||
? "border-selected-line bg-selected font-medium text-ink shadow-2xs"
|
||||
: `border-line-strong bg-surface text-ink-muted hover:border-ink-muted hover:bg-raised hover:text-ink ${dashed ? "border-dashed" : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={pressed}
|
||||
onClick={onClick}
|
||||
className={`trim-cap inline-flex items-center rounded-full border transition-colors ${SIZES[size]} ${look}`}
|
||||
className={cx(
|
||||
"inline-flex cursor-pointer items-center gap-1.5 rounded-full border transition-colors duration-150 ease",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
|
||||
size === "sm" ? "min-h-8 px-2.5 py-1 text-caption-1-medium" : "min-h-9 px-3.5 py-1.5 text-body-medium",
|
||||
pressed
|
||||
? "border-accent-500 bg-button-ghost-background text-button-ghost-foreground"
|
||||
: cx(
|
||||
"border-border-button-default bg-background-primary-default text-text-secondary hover:border-border-button-hover hover:bg-background-secondary-hover hover:text-text-primary",
|
||||
dashed && "border-dashed",
|
||||
),
|
||||
)}
|
||||
>
|
||||
{pressed && <Check className="size-3.5 shrink-0 text-brand-300" strokeWidth={2.5} aria-hidden />}
|
||||
{pressed && <RiCheckLine className="size-3.5 shrink-0" aria-hidden />}
|
||||
<span className="inline-flex items-center gap-1.5">{children}</span>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, RotateCcw, Shuffle, SlidersHorizontal, Wand2 } from "lucide-react";
|
||||
import { RiDownload2Line, RiArrowDownSLine as ChevronDown, RiResetLeftLine as RotateCcw, RiShuffleLine as Shuffle, RiEqualizerLine as SlidersHorizontal, RiMagicLine as Wand2 } from "@remixicon/react";
|
||||
import { useState, type Dispatch } from "react";
|
||||
import { APPLIANCES, CUISINES, STYLES, activeRecipeIds, mealLabel, visibleMeals } from "@/app/lib/plan";
|
||||
import { buildPlanModel, renderPlanPdf } from "@/app/lib/exportPlan";
|
||||
import type { PlanAction } from "@/app/lib/usePlanState";
|
||||
import type { FillMode, MealPlanState } from "@/app/types";
|
||||
import { Chip } from "./Chip";
|
||||
import { ApplianceIcon, StyleIcon } from "./Icons";
|
||||
import { RadioGroup } from "./RadioGroup";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
|
||||
interface ConfigPanelProps {
|
||||
state: MealPlanState;
|
||||
|
|
@ -36,6 +38,19 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
|
|||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const filledCount = activeRecipeIds(state).length;
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
async function downloadPlan() {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
// Loaded on demand so jsPDF stays out of the planner's initial bundle.
|
||||
const { jsPDF } = await import("jspdf");
|
||||
renderPlanPdf(buildPlanModel(state), jsPDF).save("meal-plan.pdf");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}
|
||||
const mealNames = visibleMeals(state.mealsPerDay).map(mealLabel).join(" · ");
|
||||
const activeFilterCount =
|
||||
(APPLIANCES.length - state.selectedAppliances.length > 0 ? 1 : 0) +
|
||||
|
|
@ -51,7 +66,7 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
|
|||
.join(" · ");
|
||||
|
||||
return (
|
||||
<section aria-labelledby="config-heading" className="rounded-xl border border-line bg-surface p-4 sm:p-5">
|
||||
<section aria-labelledby="config-heading" className="rounded-3xl border border-border-button-default bg-surface p-4 sm:p-5">
|
||||
<h2 id="config-heading" className="sr-only">
|
||||
Plan parameters
|
||||
</h2>
|
||||
|
|
@ -155,25 +170,13 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
|
|||
{/* --- Action Toolbar + Progressive Disclosure Toggle --------------- */}
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-line pt-3.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGenerate("empty")}
|
||||
disabled={isGenerating}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 disabled:opacity-60"
|
||||
>
|
||||
<Wand2 className="size-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
<Button leadingIcon={Wand2} onClick={() => onGenerate("empty")} disabled={isGenerating}>
|
||||
{isGenerating ? "Planning…" : "Auto-fill empty"}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGenerate("all")}
|
||||
disabled={isGenerating}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg border border-line-strong bg-surface px-3.5 py-2 text-sm font-medium text-ink transition-colors hover:bg-raised disabled:opacity-60"
|
||||
>
|
||||
<Shuffle className="size-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden />
|
||||
<Button variant="secondary" leadingIcon={Shuffle} onClick={() => onGenerate("all")} disabled={isGenerating}>
|
||||
Shuffle plan
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -184,6 +187,16 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
|
|||
<RotateCcw className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
|
||||
Clear
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
leadingIcon={RiDownload2Line}
|
||||
onClick={downloadPlan}
|
||||
disabled={isGenerating || isExporting || filledCount === 0}
|
||||
title="Save the week as a PDF, with the full recipe for every meal, organized by day"
|
||||
>
|
||||
{isExporting ? "Preparing PDF…" : "Download PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Copy, Repeat2, Square, SquareCheckBig, X } from "lucide-react";
|
||||
import { RiCheckLine as Check, RiFileCopyLine as Copy, RiRepeat2Line as Repeat2, RiCloseLine as X } from "@remixicon/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatAmount, formatUsd } from "@/app/lib/units";
|
||||
import type { Aisle, GroceryLine, GrocerySummary, ItemId } from "@/app/types";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { BudgetMeter } from "./BudgetMeter";
|
||||
|
||||
interface PanelProps {
|
||||
|
|
@ -35,7 +36,7 @@ export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggl
|
|||
return (
|
||||
<div>
|
||||
{/* --- Tactile Market Receipt Header -------------------------------- */}
|
||||
<div className="paper-grain rounded-xl border border-line bg-canvas p-4">
|
||||
<div className="rounded-3xl border border-border-button-default bg-canvas p-4">
|
||||
<BudgetMeter spent={summary.checkoutTotal} budget={budget} />
|
||||
|
||||
<dl className="receipt-rule mt-4 grid grid-cols-2 gap-3 pt-3.5 text-xs">
|
||||
|
|
@ -61,7 +62,7 @@ export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggl
|
|||
<div className="mt-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-serif text-base font-semibold">Shopping list</h3>
|
||||
<p className="text-xs text-ink-faint">Tap items you already have on hand</p>
|
||||
<p className="text-xs text-ink-faint">Tap “I have this” on anything in your kitchen and it drops off the bill.</p>
|
||||
</div>
|
||||
{summary.lines.length > 0 && (
|
||||
<button
|
||||
|
|
@ -102,48 +103,46 @@ export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggl
|
|||
}
|
||||
|
||||
function GroceryRow({ line, onToggleOnHand }: { line: GroceryLine; onToggleOnHand: PanelProps["onToggleOnHand"] }) {
|
||||
const { item } = line;
|
||||
const { item, onHand } = line;
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleOnHand(item.id, !line.onHand)}
|
||||
aria-pressed={line.onHand}
|
||||
aria-label={line.onHand ? `Mark ${item.name} as needed at the store` : `Mark ${item.name} as already on hand`}
|
||||
className={`flex min-h-12 w-full items-start justify-between gap-3 py-2.5 text-left text-sm transition-colors hover:bg-raised/50 ${
|
||||
line.onHand ? "text-ink-faint" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-2.5">
|
||||
<span className="mt-0.5 shrink-0 text-ink-muted">
|
||||
{line.onHand ? (
|
||||
<SquareCheckBig className="size-4 text-status-ok" strokeWidth={2} aria-hidden />
|
||||
) : (
|
||||
<Square className="size-4 text-line-strong" strokeWidth={1.75} aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className={`block leading-snug ${line.onHand ? "line-through" : "font-medium"}`}>
|
||||
{line.packages > 1 && <span className="font-mono tabular-nums">{line.packages} × </span>}
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-ink-muted">
|
||||
{item.packageLabel} · need {formatAmount(line.neededAmount, item.baseUnit)}
|
||||
{line.onHand && " · on hand"}
|
||||
</span>
|
||||
{line.usedBy.length > 1 && (
|
||||
<span className="mt-0.5 inline-flex items-center gap-1 text-xs text-ink-faint" title={line.usedBy.join(", ")}>
|
||||
<Repeat2 className="size-3 shrink-0" aria-hidden />
|
||||
Used in {line.usedBy.length} meals
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<li className="flex min-h-12 items-start justify-between gap-3 py-2.5 text-sm">
|
||||
<span className="min-w-0">
|
||||
<span className={cx("block leading-snug", onHand ? "text-ink-faint line-through" : "font-medium text-ink")}>
|
||||
{line.packages > 1 && <span className="font-mono tabular-nums">{line.packages} × </span>}
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-ink-muted">
|
||||
{item.packageLabel} · need {formatAmount(line.neededAmount, item.baseUnit)}
|
||||
</span>
|
||||
{line.usedBy.length > 1 && (
|
||||
<span className="mt-0.5 inline-flex items-center gap-1 text-xs text-ink-faint" title={line.usedBy.join(", ")}>
|
||||
<Repeat2 className="size-3 shrink-0" aria-hidden />
|
||||
Used in {line.usedBy.length} meals
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 text-right font-mono text-sm font-medium tabular-nums">
|
||||
{line.onHand ? "$0.00" : formatUsd(line.checkoutCost)}
|
||||
<span className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<span className={cx("font-mono text-sm font-medium tabular-nums", onHand ? "text-ink-faint" : "text-ink")}>
|
||||
{onHand ? "$0.00" : formatUsd(line.checkoutCost)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleOnHand(item.id, !onHand)}
|
||||
aria-pressed={onHand}
|
||||
aria-label={`I have ${item.name}`}
|
||||
title={onHand ? "Counted as $0. Tap to add it back to the bill." : "Already have it? Tap to take it off the bill."}
|
||||
className={cx(
|
||||
"inline-flex min-h-7 cursor-pointer items-center gap-1 rounded-full border px-2.5 text-caption-1-medium whitespace-nowrap transition-colors duration-150",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
|
||||
onHand
|
||||
? "border-status-ok-line bg-status-ok-bg text-status-ok"
|
||||
: "border-border-button-default bg-background-primary-default text-ink-muted hover:border-border-button-hover hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{onHand && <Check className="size-3.5 shrink-0" aria-hidden />}I have this
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -198,7 +197,7 @@ export function GroceryListDrawer({ open, onClose, ...panelProps }: DrawerProps)
|
|||
onClose={onClose}
|
||||
onClick={(e) => e.target === dialogRef.current && onClose()}
|
||||
aria-labelledby="grocery-drawer-title"
|
||||
className="sheet-dialog m-0 mt-auto max-h-[88dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border-t border-line bg-surface p-0 shadow-2xl lg:hidden"
|
||||
className="sheet-dialog m-0 mt-auto max-h-[88dvh] w-full max-w-none overflow-y-auto rounded-t-3xl border-t border-line bg-surface p-0 shadow-2xl lg:hidden"
|
||||
>
|
||||
<div className="p-4 pb-8 sm:px-6">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||
|
|
|
|||
|
|
@ -1,23 +1,10 @@
|
|||
import type { LucideProps } from "lucide-react";
|
||||
import {
|
||||
BookHeart,
|
||||
CookingPot,
|
||||
CupSoda,
|
||||
Fish,
|
||||
Flame,
|
||||
Heater,
|
||||
Leaf,
|
||||
PackageCheck,
|
||||
Salad,
|
||||
Soup,
|
||||
Timer,
|
||||
Utensils,
|
||||
Wind,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { RiHeart3Line as BookHeart, RiRestaurant2Line as CookingPot, RiCupLine as CupSoda, RiSailboatLine as Fish, RiFireLine as Flame, RiTempHotLine as Heater, RiLeafLine as Leaf, RiArchive2Line as PackageCheck, RiSeedlingLine as Salad, RiBowlLine as Soup, RiTimerLine as Timer, RiRestaurantLine as Utensils, RiWindyLine as Wind, RiFlashlightLine as Zap } from "@remixicon/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Appliance, CookingStyle } from "@/app/types";
|
||||
|
||||
export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appliance } & LucideProps) {
|
||||
type RemixiconProps = ComponentProps<typeof Flame>;
|
||||
|
||||
export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appliance } & RemixiconProps) {
|
||||
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
|
||||
switch (id) {
|
||||
case "stovetop":
|
||||
|
|
@ -37,7 +24,7 @@ export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appl
|
|||
}
|
||||
}
|
||||
|
||||
export function StyleIcon({ id, className = "size-3.5", ...props }: { id: CookingStyle } & LucideProps) {
|
||||
export function StyleIcon({ id, className = "size-3.5", ...props }: { id: CookingStyle } & RemixiconProps) {
|
||||
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
|
||||
switch (id) {
|
||||
case "caydens_corner":
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, Search, X } from "lucide-react";
|
||||
import { RiArrowDownSLine as ChevronDown, RiSearchLine as Search, RiCloseLine as X } from "@remixicon/react";
|
||||
import { useMemo, useState, type Dispatch } from "react";
|
||||
import { CATALOG, CATALOG_BY_ID, PANTRY_STAPLE_IDS } from "@/app/lib/catalog";
|
||||
import { RECIPES } from "@/app/lib/recipes";
|
||||
|
|
@ -8,6 +8,9 @@ import type { PlanAction } from "@/app/lib/usePlanState";
|
|||
import type { Aisle, ItemId, ItemPreference, MealPlanState, StoreItem } from "@/app/types";
|
||||
import { RadioGroup, type RadioOption } from "./RadioGroup";
|
||||
|
||||
// Shown beside the staples toggle so people know exactly what it marks as $0.
|
||||
const STAPLE_NAMES = PANTRY_STAPLE_IDS.map((id) => CATALOG_BY_ID.get(id)?.name).filter(Boolean).join(", ");
|
||||
|
||||
interface IngredientPanelProps {
|
||||
state: MealPlanState;
|
||||
dispatch: Dispatch<PlanAction>;
|
||||
|
|
@ -63,7 +66,7 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
|
|||
const staplesAllOnHand = PANTRY_STAPLE_IDS.every((id) => state.onHandItems.includes(id));
|
||||
|
||||
return (
|
||||
<section aria-labelledby="ingredients-heading" className="rounded-xl border border-line bg-surface">
|
||||
<section aria-labelledby="ingredients-heading" className="rounded-3xl border border-border-button-default bg-surface">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-4 sm:px-5 [&::-webkit-details-marker]:hidden">
|
||||
<div>
|
||||
|
|
@ -115,10 +118,11 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
|
|||
<RadioGroup name="Filter ingredients" options={FILTER_OPTIONS} value={filter} onChange={setFilter} size="xs" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs sm:text-sm">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreference(PANTRY_STAPLE_IDS, staplesAllOnHand ? "use" : "have")}
|
||||
aria-describedby="pantry-staples-list"
|
||||
className="min-h-8 font-medium text-brand-300 hover:underline"
|
||||
>
|
||||
{staplesAllOnHand ? "Unmark pantry staples" : "Mark basic pantry staples as on hand"}
|
||||
|
|
@ -132,6 +136,9 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
|
|||
Reset all to “Use”
|
||||
</button>
|
||||
)}
|
||||
<p id="pantry-staples-list" className="basis-full text-xs text-ink-muted">
|
||||
Staples: {STAPLE_NAMES}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-[28rem] space-y-5 overflow-y-auto pr-1">
|
||||
|
|
@ -139,7 +146,8 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
|
|||
|
||||
{groups.map(([aisle, items]) => (
|
||||
<section key={aisle}>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-line bg-surface py-1.5">
|
||||
{/* z-20: BoardUI segment buttons are z-10, so the sticky header must sit above them while rows scroll under it */}
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-line bg-surface py-1.5">
|
||||
<h3 className="font-mono text-xs font-semibold tracking-wider text-ink-muted uppercase">{aisle}</h3>
|
||||
<AisleBulkToggle itemIds={items.map((i) => i.id)} avoidedItems={state.avoidedItems} onSet={setPreference} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, ArrowLeftRight, Plus, X } from "lucide-react";
|
||||
import { RiAlertLine as AlertTriangle, RiArrowLeftRightLine as ArrowLeftRight, RiAddLine as Plus, RiCloseLine as X } from "@remixicon/react";
|
||||
import { useState, type Dispatch } from "react";
|
||||
import { APPLIANCES, DAY_NAMES, activeSlots, describeBlockers, isCookable, mealLabel, recipeBlockers, slotKey } from "@/app/lib/plan";
|
||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||
|
|
@ -47,7 +47,7 @@ export function MealGrid({ state, dispatch, mealCosts, onOpenSlot, onViewSlot, o
|
|||
const hasAnyMeal = slots.some((s) => s.recipeId !== null);
|
||||
|
||||
return (
|
||||
<article key={day} className="flex flex-col overflow-hidden rounded-xl border border-line bg-surface">
|
||||
<article key={day} className="flex flex-col overflow-hidden rounded-3xl border border-border-button-default bg-surface">
|
||||
{/* Architectural day header */}
|
||||
<div className="flex items-baseline justify-between border-b border-line bg-raised/65 px-4 py-2.5">
|
||||
<h3 className="font-serif text-base font-semibold tracking-tight text-ink">{DAY_NAMES[day]}</h3>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, Repeat2, Search, SlidersHorizontal, X } from "lucide-react";
|
||||
import { RiArrowDownSLine as ChevronDown, RiArrowUpSLine as ChevronUp, RiRepeat2Line as Repeat2, RiSearchLine as Search, RiEqualizerLine as SlidersHorizontal, RiCloseLine as X } from "@remixicon/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Cart } from "@/app/lib/grocery";
|
||||
import { CUISINES, DAY_NAMES, STYLES, activeRecipeIds, describeBlockers, mealKindFor, mealLabel, recipeBlockers, toggle } from "@/app/lib/plan";
|
||||
|
|
@ -10,6 +10,7 @@ import type { CookingStyle, Cuisine, MealPlanState, Recipe } from "@/app/types";
|
|||
import { Chip } from "./Chip";
|
||||
import { StyleIcon } from "./Icons";
|
||||
import { RecipeDetail } from "./RecipeDetail";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
|
||||
interface MealSelectorModalProps {
|
||||
slotKey: string | null;
|
||||
|
|
@ -37,7 +38,7 @@ export function MealSelectorModal({ slotKey, state, onPick, onClose }: MealSelec
|
|||
onClose={onClose}
|
||||
onClick={(e) => e.target === dialogRef.current && onClose()}
|
||||
aria-labelledby="selector-title"
|
||||
className="m-0 mt-auto max-h-[90dvh] w-full max-w-none overflow-hidden rounded-t-2xl border border-line bg-surface p-0 shadow-2xl sm:m-auto sm:max-w-2xl sm:rounded-2xl"
|
||||
className="m-0 mt-auto max-h-[90dvh] w-full max-w-none overflow-hidden rounded-t-3xl border border-line bg-surface p-0 shadow-2xl sm:m-auto sm:max-w-2xl sm:rounded-3xl"
|
||||
>
|
||||
{slotKey && <SelectorContent key={slotKey} slotKey={slotKey} state={state} onPick={onPick} onClose={onClose} />}
|
||||
</dialog>
|
||||
|
|
@ -278,13 +279,9 @@ function SelectorContent({
|
|||
<div id={`recipe-${recipe.id}`} className="border-t border-line bg-canvas/50 px-3.5 py-4">
|
||||
<RecipeDetail recipe={recipe} servings={state.householdSize} showMeta={false} compact />
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(recipe.id)}
|
||||
className="trim-cap mt-4 min-h-10 w-full rounded-lg bg-brand-600 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-brand-500"
|
||||
>
|
||||
<Button className="mt-4 w-full" onClick={() => onPick(recipe.id)}>
|
||||
Add to {DAY_NAMES[dayIndex]} {mealLabel(mealNumber).toLowerCase()}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Plus, Repeat2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface OverlapDish {
|
||||
id: string;
|
||||
title: string;
|
||||
cuisine: string;
|
||||
standaloneCheckout: number;
|
||||
}
|
||||
|
||||
export interface OverlapComboSnapshot {
|
||||
checkoutTotal: number;
|
||||
usedTotal: number;
|
||||
carryoverValue: number;
|
||||
sharedNames: string[];
|
||||
packageCount: number;
|
||||
}
|
||||
|
||||
interface OverlapExplorerProps {
|
||||
dishes: OverlapDish[];
|
||||
/** Keyed by sorted comma-joined dish IDs; precomputed on the server so zero catalog data ships to the landing bundle. */
|
||||
combos: Record<string, OverlapComboSnapshot>;
|
||||
}
|
||||
|
||||
const fmt = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
export function OverlapExplorer({ dishes, combos }: OverlapExplorerProps) {
|
||||
const [selected, setSelected] = useState<string[]>(() => dishes.slice(0, 2).map((d) => d.id));
|
||||
|
||||
function toggleDish(id: string) {
|
||||
setSelected((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.length > 1 ? prev.filter((x) => x !== id) : prev; // keep at least 1 selected
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
}
|
||||
|
||||
const comboKey = [...selected].sort().join(",");
|
||||
const current = combos[comboKey];
|
||||
const naiveSum = dishes.filter((d) => selected.includes(d.id)).reduce((acc, d) => acc + d.standaloneCheckout, 0);
|
||||
const saved = Math.max(0, naiveSum - (current?.checkoutTotal ?? 0));
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 rounded-xl border border-line-strong bg-surface p-5 sm:p-7 lg:grid-cols-[1.15fr_1fr]">
|
||||
<div>
|
||||
<p className="font-mono text-xs tracking-wider text-brand-300 uppercase">Interactive checkout math · 2 people</p>
|
||||
<h3 className="mt-1 font-serif text-2xl font-semibold tracking-tight text-balance">
|
||||
Toggle dishes to watch shared packages drop the bill
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-ink-muted">
|
||||
Grocery stores sell whole bags of onions, rice, and spices—not single tablespoons. Selecting meals that reuse open
|
||||
packages slashes what each extra dinner adds at the register.
|
||||
</p>
|
||||
|
||||
<ul className="mt-5 space-y-2" aria-label="Sample dishes">
|
||||
{dishes.map((dish) => {
|
||||
const active = selected.includes(dish.id);
|
||||
return (
|
||||
<li key={dish.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => toggleDish(dish.id)}
|
||||
className={`flex min-h-12 w-full items-center justify-between gap-3 rounded-lg border px-3.5 py-2.5 text-left transition-colors ${
|
||||
active
|
||||
? "border-selected-line bg-selected text-ink"
|
||||
: "border-line bg-canvas text-ink-muted hover:border-line-strong hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
className={`grid size-5 shrink-0 place-items-center rounded border ${
|
||||
active ? "border-brand-600 bg-brand-600 text-on-accent" : "border-line-strong bg-surface"
|
||||
}`}
|
||||
>
|
||||
{active ? <Check className="size-3.5" strokeWidth={2.5} aria-hidden /> : <Plus className="size-3 text-ink-faint" aria-hidden />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-ink">{dish.title}</span>
|
||||
<span className="block text-xs text-ink-faint">{dish.cuisine}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
|
||||
{fmt(dish.standaloneCheckout)} alone
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Simulated paper receipt */}
|
||||
<div className="paper-grain flex flex-col justify-between rounded-lg border border-line bg-canvas p-5 font-mono text-sm">
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-line pb-3 text-xs text-ink-faint uppercase">
|
||||
<span>Checkout Receipt</span>
|
||||
<span>{selected.length} {selected.length === 1 ? "meal" : "meals"}</span>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 space-y-2.5 text-xs sm:text-sm">
|
||||
<div className="flex justify-between text-ink-muted">
|
||||
<dt>Buying each meal separately</dt>
|
||||
<dd className="line-through tabular-nums">{fmt(naiveSum)}</dd>
|
||||
</div>
|
||||
{saved > 0.01 && (
|
||||
<div className="flex justify-between font-semibold text-status-ok">
|
||||
<dt>Shared-package savings</dt>
|
||||
<dd className="tabular-nums">−{fmt(saved)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="receipt-rule flex items-baseline justify-between pt-3 text-base font-bold text-ink">
|
||||
<dt className="font-sans text-sm font-semibold">Combined register total</dt>
|
||||
<dd className="text-lg tabular-nums">{fmt(current?.checkoutTotal ?? 0)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 rounded-md border border-line bg-surface p-3 font-sans text-xs">
|
||||
<div>
|
||||
<dt className="text-ink-faint">Food eaten</dt>
|
||||
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.usedTotal ?? 0)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-ink-faint">Kept in pantry</dt>
|
||||
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.carryoverValue ?? 0)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-line pt-3 font-sans text-xs text-ink-muted">
|
||||
{current && current.sharedNames.length > 0 ? (
|
||||
<p className="flex items-start gap-1.5">
|
||||
<Repeat2 className="mt-0.5 size-3.5 shrink-0 text-status-ok" strokeWidth={2} aria-hidden />
|
||||
<span>
|
||||
<strong className="font-semibold text-ink">Reused across meals:</strong> {current.sharedNames.slice(0, 5).join(", ")}
|
||||
{current.sharedNames.length > 5 ? ` +${current.sharedNames.length - 5} more` : ""}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p>Select a second dish to see which store packages overlap in your cart.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, RotateCcw, ShoppingBag, X } from "lucide-react";
|
||||
import { RiErrorWarningLine as AlertCircle, RiResetLeftLine as RotateCcw, RiShoppingBag3Line as ShoppingBag, RiCloseLine as X } from "@remixicon/react";
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { generateMealPlanAction } from "@/app/actions";
|
||||
import { Cart, buildGroceryList } from "@/app/lib/grocery";
|
||||
|
|
@ -17,6 +17,7 @@ import { MealGrid } from "./MealGrid";
|
|||
import { MealSelectorModal } from "./MealSelectorModal";
|
||||
import { Navbar } from "./Navbar";
|
||||
import { RecipeDialog } from "./RecipeDialog";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
|
||||
interface UndoSnapshot {
|
||||
message: string;
|
||||
|
|
@ -99,7 +100,7 @@ export default function PlannerDashboard() {
|
|||
|
||||
<main className="mx-auto max-w-7xl px-4 pt-6 pb-28 sm:px-6 lg:pb-12">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left: compact control bar -> immediate MealGrid -> Pantry below */}
|
||||
{/* Left: control bar -> pantry & exclusions -> MealGrid */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<ConfigPanel
|
||||
state={state}
|
||||
|
|
@ -133,6 +134,8 @@ export default function PlannerDashboard() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<IngredientPanel state={state} dispatch={dispatch} />
|
||||
|
||||
<div className={isGenerating ? "pointer-events-none opacity-60 transition-opacity" : "transition-opacity"}>
|
||||
<MealGrid
|
||||
state={state}
|
||||
|
|
@ -143,13 +146,11 @@ export default function PlannerDashboard() {
|
|||
onRemoveSlot={handleRemoveSlot}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<IngredientPanel state={state} dispatch={dispatch} />
|
||||
</div>
|
||||
|
||||
{/* Right: persistent Market Receipt summary (desktop) */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-xl border border-line bg-surface p-5">
|
||||
<div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-3xl border border-border-button-default bg-surface p-5">
|
||||
<h2 className="mb-3 font-serif text-xl font-semibold">Market receipt</h2>
|
||||
<GrocerySummaryPanel
|
||||
summary={summary}
|
||||
|
|
@ -170,14 +171,9 @@ export default function PlannerDashboard() {
|
|||
className="fixed bottom-20 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-line-strong bg-surface px-4 py-2.5 text-sm shadow-lg lg:bottom-6"
|
||||
>
|
||||
<span className="text-ink">{undo.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
className="trim-cap inline-flex min-h-8 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1 text-xs font-semibold text-on-accent hover:bg-brand-500"
|
||||
>
|
||||
<RotateCcw className="size-3.5" aria-hidden />
|
||||
<Button size="small" leadingIcon={RotateCcw} onClick={handleUndo}>
|
||||
Undo
|
||||
</button>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUndo(null)}
|
||||
|
|
@ -195,14 +191,9 @@ export default function PlannerDashboard() {
|
|||
<div className="flex-1">
|
||||
<BudgetMeter spent={summary.checkoutTotal} budget={state.targetBudget} compact />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGroceriesOpen(true)}
|
||||
className="trim-cap inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs hover:bg-brand-500"
|
||||
>
|
||||
<ShoppingBag className="size-4" strokeWidth={2} aria-hidden />
|
||||
<Button className="shrink-0" leadingIcon={ShoppingBag} onClick={() => setGroceriesOpen(true)}>
|
||||
Receipt ({summary.lines.length})
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{mealCount} meals planned, estimated checkout {summary.checkoutTotal.toFixed(2)} dollars
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Skeleton } from "@/components/base/skeleton/skeleton";
|
||||
|
||||
const PlannerDashboard = dynamic(() => import("./PlannerDashboard"), {
|
||||
ssr: false,
|
||||
|
|
@ -13,18 +14,18 @@ export function PlannerLoader() {
|
|||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse" aria-busy="true" aria-label="Loading planner">
|
||||
<div aria-busy="true" aria-label="Loading planner">
|
||||
<div className="h-15 border-b border-line bg-surface" />
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-4 pt-6 sm:px-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<div className="h-36 rounded-xl bg-raised" />
|
||||
<Skeleton className="h-36 rounded-3xl" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="h-44 rounded-xl bg-raised" />
|
||||
<Skeleton key={i} className="h-44 rounded-3xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden h-[32rem] rounded-xl bg-raised lg:block" />
|
||||
<Skeleton className="hidden h-[32rem] rounded-3xl lg:block" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, type KeyboardEvent, type ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { SegmentedControl, SegmentedControlItem } from "@/components/base/segmented-control/segmented-control";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
export interface RadioOption<T extends string | number> {
|
||||
value: T;
|
||||
label: ReactNode;
|
||||
/** Extra classes on the selected segment (e.g. a status tint). */
|
||||
activeClass?: string;
|
||||
}
|
||||
|
||||
|
|
@ -17,79 +20,32 @@ interface RadioGroupProps<T extends string | number> {
|
|||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessible segmented control implementing WAI-ARIA `radiogroup` roving tabIndex
|
||||
* and arrow/Home/End keyboard navigation (1 Tab stop per group instead of N).
|
||||
*/
|
||||
export function RadioGroup<T extends string | number>({
|
||||
name,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
size = "sm",
|
||||
fullWidth = false,
|
||||
}: RadioGroupProps<T>) {
|
||||
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
||||
const currentIndex = Math.max(0, options.findIndex((o) => o.value === value));
|
||||
let nextIndex = -1;
|
||||
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
nextIndex = (currentIndex + 1) % options.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
nextIndex = (currentIndex - 1 + options.length) % options.length;
|
||||
} else if (e.key === "Home") {
|
||||
nextIndex = 0;
|
||||
} else if (e.key === "End") {
|
||||
nextIndex = options.length - 1;
|
||||
}
|
||||
|
||||
if (nextIndex !== -1) {
|
||||
e.preventDefault();
|
||||
onChange(options[nextIndex].value);
|
||||
buttonsRef.current[nextIndex]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const pad =
|
||||
size === "xs"
|
||||
? fullWidth
|
||||
? "min-h-8 flex-1 min-w-0 px-1.5 py-1 text-xs"
|
||||
: "min-h-8 px-2.5 py-1 text-xs"
|
||||
: fullWidth
|
||||
? "min-h-9 flex-1 min-w-0 px-2 py-1.5 text-sm"
|
||||
: "min-h-9 min-w-9 px-3 py-1.5 text-sm";
|
||||
|
||||
/** Single-select segmented control: BoardUI's SegmentedControl (React Aria handles roving focus + arrow keys). */
|
||||
export function RadioGroup<T extends string | number>({ name, options, value, onChange, size = "sm", fullWidth = false }: RadioGroupProps<T>) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
<SegmentedControl
|
||||
aria-label={name}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`${fullWidth ? "flex w-full" : "inline-flex shrink-0"} max-w-full rounded-lg border border-line-strong bg-raised p-0.5`}
|
||||
selectedKeys={[value]}
|
||||
onSelectionChange={(keys) => {
|
||||
const next = options.find((o) => keys.has(o.value));
|
||||
if (next) onChange(next.value);
|
||||
}}
|
||||
className={cx("max-w-full", fullWidth ? "flex w-full" : "shrink-0")}
|
||||
>
|
||||
{options.map((option, i) => {
|
||||
const checked = option.value === value;
|
||||
const activeStyle = option.activeClass ?? "bg-surface text-ink border border-selected-line/60 shadow-2xs";
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
ref={(el) => {
|
||||
buttonsRef.current[i] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
tabIndex={checked ? 0 : -1}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`trim-cap inline-flex items-center justify-center rounded-md font-medium tabular-nums transition-colors ${pad} ${
|
||||
checked ? activeStyle : "border border-transparent text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{options.map((option) => (
|
||||
<SegmentedControlItem
|
||||
key={String(option.value)}
|
||||
id={option.value}
|
||||
className={cx(
|
||||
"tabular-nums",
|
||||
size === "xs" && "min-h-7 px-2 text-caption-1-medium",
|
||||
fullWidth && "min-w-0 flex-1",
|
||||
option.value === value && option.activeClass,
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</SegmentedControlItem>
|
||||
))}
|
||||
</SegmentedControl>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { BookHeart, Search, SlidersHorizontal } from "lucide-react";
|
||||
import { RiHeart3Line as BookHeart, RiSearchLine as Search, RiEqualizerLine as SlidersHorizontal } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { ViewTransition, useMemo, useState, type ReactNode } from "react";
|
||||
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES, toggle } from "@/app/lib/plan";
|
||||
|
|
@ -91,7 +91,7 @@ export function RecipeBrowser({ recipes }: { recipes: RecipeSummary[] }) {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-5 ${mobileFiltersOpen ? "block rounded-xl border border-line bg-surface p-4" : "hidden"} lg:block lg:border-0 lg:bg-transparent lg:p-0`}>
|
||||
<div className={`space-y-5 ${mobileFiltersOpen ? "block rounded-3xl border border-border-button-default bg-surface p-4" : "hidden"} lg:block lg:border-0 lg:bg-transparent lg:p-0`}>
|
||||
<FilterGroup label="Course">
|
||||
{(
|
||||
[
|
||||
|
|
@ -174,7 +174,7 @@ export function RecipeBrowser({ recipes }: { recipes: RecipeSummary[] }) {
|
|||
<li key={r.id}>
|
||||
<Link
|
||||
href={`/recipes/${r.id}`}
|
||||
className="group flex h-full flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
|
||||
className="group flex h-full flex-col rounded-3xl border border-border-button-default bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] tracking-wide text-ink-faint uppercase">
|
||||
{r.styles.includes("caydens_corner") && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Lightbulb } from "lucide-react";
|
||||
import { RiCheckLine as Check, RiLightbulbLine as Lightbulb } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { ingredientLines } from "@/app/lib/display";
|
||||
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES } from "@/app/lib/plan";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, ArrowRight, ArrowRightLeft, Trash2, X } from "lucide-react";
|
||||
import { RiAlertLine as AlertTriangle, RiArrowRightLine as ArrowRight, RiArrowLeftRightLine as ArrowRightLeft, RiDeleteBinLine as Trash2, RiCloseLine as X } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { DAY_NAMES, describeBlockers, isCookable, mealLabel, recipeBlockers } from "@/app/lib/plan";
|
||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||
import type { MealPlanState } from "@/app/types";
|
||||
import { RecipeDetail, RecipeMeta } from "./RecipeDetail";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
|
||||
interface RecipeDialogProps {
|
||||
slotKey: string | null;
|
||||
|
|
@ -39,7 +40,7 @@ export function RecipeDialog({ slotKey, state, onClose, onSwap, onRemove }: Reci
|
|||
onClose={onClose}
|
||||
onClick={(e) => e.target === dialogRef.current && onClose()}
|
||||
aria-labelledby="recipe-dialog-title"
|
||||
className="m-0 mt-auto max-h-[92dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-3xl sm:rounded-2xl"
|
||||
className="m-0 mt-auto max-h-[92dvh] w-full max-w-none overflow-y-auto rounded-t-3xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-3xl sm:rounded-3xl"
|
||||
>
|
||||
{open && recipe && slotKey && (
|
||||
<div className="p-5 sm:p-7">
|
||||
|
|
@ -79,14 +80,9 @@ export function RecipeDialog({ slotKey, state, onClose, onSwap, onRemove }: Reci
|
|||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-2.5 border-t border-line pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSwap(slotKey)}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-brand-500"
|
||||
>
|
||||
<ArrowRightLeft className="size-4" aria-hidden />
|
||||
<Button leadingIcon={ArrowRightLeft} onClick={() => onSwap(slotKey)}>
|
||||
Replace dish
|
||||
</button>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(slotKey, recipe.title)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Flame, Minus, Plus } from "lucide-react";
|
||||
import { RiFireLine as Flame, RiSubtractLine as Minus, RiAddLine as Plus } from "@remixicon/react";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { Recipe } from "@/app/types";
|
||||
import { RecipeDetail } from "./RecipeDetail";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Pause, Play, Repeat2, ShoppingBag } from "lucide-react";
|
||||
import { RiPauseLine as Pause, RiPlayLine as Play, RiRepeat2Line as Repeat2, RiShoppingBag3Line as ShoppingBag } from "@remixicon/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface PreviewSet {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { BookHeart, BookOpen, CalendarRange, UtensilsCrossed } from "lucide-react";
|
||||
import { RiHeart3Line as BookHeart, RiBookOpenLine as BookOpen, RiCalendarLine as CalendarRange, RiRestaurantLine as UtensilsCrossed } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { buttonStyles } from "@/components/base/buttons/button";
|
||||
import { ThemeToggle } from "@/components/application/theme/theme-toggle";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
export type ActiveNav = "home" | "recipes" | "caydens-corner" | "plan";
|
||||
type ActiveNav = "home" | "recipes" | "caydens-corner" | "plan";
|
||||
|
||||
export function Logo({ small = false, subtitle }: { small?: boolean; subtitle?: ReactNode }) {
|
||||
return (
|
||||
|
|
@ -58,12 +60,15 @@ interface SiteHeaderProps {
|
|||
|
||||
export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderProps) {
|
||||
const linkClass = (isCurrent: boolean) =>
|
||||
`trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors sm:px-3 ${
|
||||
isCurrent ? "bg-selected text-ink" : "text-ink-muted hover:bg-raised hover:text-ink"
|
||||
}`;
|
||||
cx(
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-2lg px-2.5 text-body-medium transition-colors duration-150 sm:px-3",
|
||||
isCurrent
|
||||
? "bg-background-secondary-default text-text-primary"
|
||||
: "text-text-secondary hover:bg-background-secondary-hover hover:text-text-primary",
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-line bg-surface/90 backdrop-blur-md">
|
||||
<header className="sticky top-0 z-30 border-b border-separator-border bg-background-primary-default/90 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-2 px-4 py-2.5 sm:gap-4 sm:px-6">
|
||||
<Logo subtitle={subtitle} />
|
||||
|
||||
|
|
@ -91,9 +96,9 @@ export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderP
|
|||
{active !== "plan" && (
|
||||
<Link
|
||||
href="/plan"
|
||||
className="trim-cap ml-1 inline-flex min-h-10 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 sm:px-4"
|
||||
className={cx(buttonStyles.base, buttonStyles.size.medium, buttonStyles.variant.primary, "ml-1 gap-1.5 px-3")}
|
||||
>
|
||||
<CalendarRange className="size-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
<CalendarRange className={buttonStyles.icon.medium} aria-hidden />
|
||||
<span className="sm:hidden">Planner</span>
|
||||
<span className="hidden sm:inline">Open planner</span>
|
||||
</Link>
|
||||
|
|
@ -102,7 +107,7 @@ export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderP
|
|||
|
||||
{rightSlot}
|
||||
|
||||
<ThemeToggle />
|
||||
<ThemeToggle appearance="segmented" size="small" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const STORAGE_KEY = "plan-my-meals:theme";
|
||||
const EVENT_NAME = "plan-my-meals:theme-change";
|
||||
|
||||
type ResolvedTheme = "light" | "dark";
|
||||
|
||||
function getSnapshot(): ResolvedTheme {
|
||||
const explicit = document.documentElement.dataset.theme;
|
||||
if (explicit === "light" || explicit === "dark") return explicit;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mq.addEventListener("change", callback);
|
||||
window.addEventListener(EVENT_NAME, callback);
|
||||
return () => {
|
||||
mq.removeEventListener("change", callback);
|
||||
window.removeEventListener(EVENT_NAME, callback);
|
||||
};
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
// Server snapshot returns null so hydration never mismatches; button renders a stable shell first.
|
||||
const theme = useSyncExternalStore<ResolvedTheme | null>(subscribe, getSnapshot, () => null);
|
||||
|
||||
function toggleTheme() {
|
||||
const next: ResolvedTheme = (theme ?? getSnapshot()) === "dark" ? "light" : "dark";
|
||||
document.documentElement.dataset.theme = next;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Ignore blocked storage; dataset.theme still updates the active session.
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT_NAME));
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Switch to light paper theme" : "Switch to dark slate theme"}
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-lg border border-line bg-surface text-ink-muted transition-colors hover:border-line-strong hover:bg-raised hover:text-ink"
|
||||
>
|
||||
{theme === "dark" ? <Sun className="size-4" strokeWidth={1.75} aria-hidden /> : <Moon className="size-4" strokeWidth={1.75} aria-hidden />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue