// 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 && Edit prices → }
);
}
// ── 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 (
onChange(Math.max(min, value - 1))}>−
{value}
= max)} disabled={value >= max}
onClick={() => onChange(Math.min(max, value + 1))}>+
);
}
// ─────────────────────────────────────────────────────────────
// 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
setAskServes(false)} style={{ background: 'transparent', border: 'none',
cursor: 'pointer', color: 'var(--ink-3)', fontFamily: 'Bricolage Grotesque', fontSize: 13 }}>Cancel
)}
);
}
// ── 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 = (
setOpen((o) => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6,
height: 38, padding: '0 14px', borderRadius: 999, cursor: 'pointer', background: 'rgba(255,255,255,0.04)',
border: '1px solid var(--hairline)', color: 'var(--ink-2)', fontFamily: 'Bricolage Grotesque',
fontSize: 13, fontWeight: 500 }}>
How it's worked out
);
// Mobile: header (kicker + refresh icon), stacked figure, sub, then disclosure.
if (inlineRefresh) {
return (
Cost{flagged ? ' · needs a look' : ''}
runCalc(true)} title="Recalculate" style={{ cursor: 'pointer',
width: 32, height: 32, borderRadius: 999, display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', background: 'rgba(240,168,80,0.12)', border: '1px solid rgba(240,168,80,0.4)',
color: 'var(--olive)', flexShrink: 0 }}>
{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}
runCalc(true)} title="Recalculate" style={{ display: 'inline-flex', alignItems: 'center',
gap: 6, height: 38, padding: '0 14px', borderRadius: 999, cursor: 'pointer', background: 'rgba(255,255,255,0.04)',
border: '1px solid var(--hairline)', color: 'var(--ink-2)', fontFamily: 'Bricolage Grotesque',
fontSize: 13, fontWeight: 500 }}>
Refresh
{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 });