// app/cost.jsx — recipe-costing UI. The "Calculate / Refresh cost" control (the // cost analogue of the photo-enhance control), the cost breakdown, and the // cook-mode servings scaler. All numbers come from the adapter (mock computes, // live calls the API); these components only format + drive the actions. // // Cost vocabulary: every figure is an estimate, so it always reads with a // leading "≈". Per-serve is the headline; full ("to make") is secondary. const lcMoney = (n) => window.LCDomain.money(n); // "≈ $2.60" const lcDollars = (n) => window.LCDomain.fmtMoney(n); // "$2.60" function CostSpinner({ size = 14 }) { return ; } const costKicker = { fontFamily: 'Bricolage Grotesque', fontSize: 10, fontWeight: 600, letterSpacing: '0.28em', textTransform: 'uppercase', color: 'var(--olive)' }; // ── headline figure: $2.60 /serve · $10.40 to make (with inline refresh) ── function CostFigure({ perServing, full, rough, big, onRefresh }) { return (
{lcMoney(perServing)} / serve {rough && · rough} {full != null && ( {lcMoney(full)} to make {onRefresh && ( )} )}
); } // ── per-ingredient breakdown (disclosure) ── function CostBreakdown({ breakdown, onReview, onEditIngredients }) { if (!breakdown) return null; const rows = breakdown.filter((b) => b.status !== 'section'); const flagged = rows.filter((b) => b.status === 'needs_attention'); const ok = rows.filter((b) => b.status !== 'needs_attention'); return (
{/* All faulty lines grouped in ONE box, error on the same line, one CTA. */} {flagged.length > 0 && (
{flagged.length} ingredient{flagged.length === 1 ? '' : 's'} need{flagged.length === 1 ? 's' : ''} a look
{flagged.map((b, i) => (
{b.line} — {b.note || 'needs a look'}
))}
Edit ingredients →
)} {ok.map((b, i) => { const ignored = b.status === 'ignored'; return (
{b.line} {b.item && · {b.item}{b.qtyLabel ? ` · ${b.qtyLabel}` : ''}{b.isStaple ? ' · staple' : ''}} {ignored ? '—' : b.lineCost != null ? lcDollars(b.lineCost) : '—'}
); })}
Prices are AU estimates from your ingredient list — staples you keep on hand still count toward the cost. {onReview && }
); } // ── small "serves N" stepper ── function ServeStepper({ value, onChange, min = 1, max = 24 }) { const btn = (disabled) => ({ width: 30, height: 30, borderRadius: 999, cursor: disabled ? 'default' : 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, background: disabled ? 'transparent' : 'rgba(255,255,255,0.05)', border: '1px solid var(--hairline)', color: disabled ? 'var(--ink-4)' : 'var(--ink-2)', fontSize: 16, fontWeight: 600, lineHeight: 1, opacity: disabled ? 0.5 : 1 }); return ( {value} ); } // ───────────────────────────────────────────────────────────── // CostBar — the recipe-screen cost control (Calculate · Costing · Costed · // Needs-attention). Mirrors the photo-enhance control: per-recipe, on demand. // ───────────────────────────────────────────────────────────── function CostBar({ recipe, compact, inlineRefresh }) { const { calculateCost, saveRecipe, go } = useApp(); const c = window.LCDomain.costInfo(recipe); const [open, setOpen] = React.useState(false); // breakdown disclosure const [askServes, setAskServes] = React.useState(false); const [serves, setServes] = React.useState(c.servingsCount || 4); // Auto-expand the breakdown when a calculation resolves to needs_attention so // the user sees what to fix; collapse it again when a re-run comes back clean. const prevStatus = React.useRef(c.status); React.useEffect(() => { if (prevStatus.current === 'costing') { if (c.status === 'needs_attention') setOpen(true); else if (c.status === 'costed') setOpen(false); } prevStatus.current = c.status; }, [c.status]); const card = { background: 'rgba(255,255,255,0.03)', border: '1px solid var(--hairline)', borderRadius: 14, padding: compact ? '16px 18px' : '18px 22px' }; const review = () => go('edit', { recipeId: recipe.id }); const runCalc = (force) => { if (c.servingsCount == null && !force) { setAskServes(true); return; } calculateCost(recipe.id, { force: !!force }); }; const confirmServes = () => { // set servings first (the costing gate), then calculate saveRecipe({ ...recipe, servings: `serves ${serves}`, servingsCount: serves }); setAskServes(false); setTimeout(() => calculateCost(recipe.id, { force: true }), 30); }; // ── costing ── if (c.status === 'costing') { return (
Working out the cost…
reading your ingredients and matching prices
); } // ── not costed yet ── if (c.status === 'none') { return (
{!askServes ? (
Cost
Not calculated yet
A quick per-serve estimate from your ingredients.
runCalc(false)}> Calculate cost
) : (
One thing first
How many does this serve?
We split the total across servings.
Calculate
)}
); } // ── costed / needs_attention ── const flagged = c.status === 'needs_attention'; const priced = (c.breakdown || []).filter((b) => b.status === 'priced' || b.status === 'needs_attention').length; const cardStyle = { ...card, border: flagged ? '1px solid rgba(240,168,80,0.32)' : '1px solid var(--hairline)', background: flagged ? 'rgba(240,168,80,0.05)' : 'rgba(255,255,255,0.03)' }; const subLine = (
estimated from {priced} ingredient{priced === 1 ? '' : 's'} {c.flaggedCount > 0 && · {c.flaggedCount} need{c.flaggedCount === 1 ? 's' : ''} a look}
); const workedOutBtn = ( ); // Mobile: header (kicker + refresh icon), stacked figure, sub, then disclosure. if (inlineRefresh) { return (
Cost{flagged ? ' · needs a look' : ''}
{lcMoney(c.perServing)} / serve {flagged && · rough} {c.full != null && ( ·{lcMoney(c.full)} to make )}
{subLine}
{workedOutBtn}
{open && go('settings')} onEditIngredients={review} />}
); } // Desktop / tablet return (
Cost{flagged ? ' · needs a look' : ''}
{subLine}
{workedOutBtn}
{open && go('settings')} onEditIngredients={review} />}
); } // ───────────────────────────────────────────────────────────── // CookScaleControl — servings scaler for cook mode. Controlled by the parent; // shows the scaled total cost so the figure tracks the serving count. // ───────────────────────────────────────────────────────────── function CookScaleControl({ recipe, servings, setServings, compact }) { const c = window.LCDomain.costInfo(recipe); const base = c.servingsCount || servings; const scaledTotal = c.perServing != null ? c.perServing * servings : null; return (
Serves {scaledTotal != null && ( <> {lcMoney(scaledTotal)} {servings === base ? 'total' : `was ${base}`} )}
); } Object.assign(window, { CostBar, CostBreakdown, ServeStepper, CookScaleControl, CostFigure, lcMoney, lcDollars });