'use client'; /** * — bare-skeleton renderer for a single modifier * group (toppings, sauce, bread type, …) per PRD §8.4. * * DESIGN ME — modifier group on the product page; the group + selections * come from useProductPage() via props. The data contract is what matters: * * - SINGLE → renders as , max picks = 1 * - MULTIPLE → renders as , picks bounded by min..max * - effectiveMax === 0 → group is hidden entirely (variant-disable convention) * - available === false → modifier is disabled with "Sold out" badge * - free counter shows "{used} of {freeQuantity} free" when freeQuantity > 0 * - defaultModifierIds + isDefault drive first-render checked state * - client-side guards mirror min/max (UX); the SERVER is authoritative — wire * `MODIFIER_VALIDATION_FAILED` errors back to the user when add-to-cart fails * * The price-delta side: this component does NOT compute totals. The server * runs the free-allocation policy and returns the line's `unitPrice` / * `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart. * * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with. */ import type { Modifier, ModifierGroup } from 'brainerce'; // The pure selection logic (defaults, validation, wire-format adapter) lives // in core (zero markup) so that `core/hooks/use-product-page` never imports // from `ui/`. Re-exported here for backward compatibility with existing imports. export { buildInitialSelections, toModifierSelections, validateSelections, } from '@/core/lib/product-options'; interface ModifierGroupSelectorProps { /** * Effective group as returned by `GET /products/:id` (overrides already * applied — `min`, `max`, `freeQuantity`, etc. are the values the server * will validate against for the active variant). */ group: ModifierGroup; /** Currently-selected modifier IDs in click-order. */ value: string[]; onChange: (next: string[]) => void; /** Disable all controls (e.g., while add-to-cart is in flight). */ disabled?: boolean; } export function ModifierGroupSelector({ group, value, onChange, disabled = false, }: ModifierGroupSelectorProps) { // PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden. if (group.max === 0) return null; const isSingle = group.selectionType === 'SINGLE'; // Client-side bound — server is authoritative. const effectiveMax = isSingle ? 1 : (group.max ?? Infinity); const handleToggle = (modifierId: string, checked: boolean) => { if (disabled) return; if (isSingle) { // Radio behavior: replace the selection. onChange(checked ? [modifierId] : []); return; } if (checked) { if (value.length >= effectiveMax) return; // would exceed max — ignore the click if (value.includes(modifierId)) return; onChange([...value, modifierId]); } else { onChange(value.filter((id) => id !== modifierId)); } }; const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position); // Counter: how many of the picked items are within the "free" window. const usedFreeSlots = Math.min(value.length, group.freeQuantity); const showFreeCounter = group.freeQuantity > 0; return (
{group.name} {group.required && } {ruleSummary(group)} {group.description &&

{group.description}

} {showFreeCounter && (

{usedFreeSlots} of {group.freeQuantity} free

)}
); } /** * Renders the modifier's price contribution next to its name. * * Negative deltas display with a leading minus sign (downsell modifiers per * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right * affordance for a free option. */ function ModifierPriceLabel({ modifier }: { modifier: Modifier }) { const delta = parseFloat(modifier.priceDelta); if (Number.isNaN(delta) || delta === 0) return null; const sign = delta > 0 ? '+' : ''; return ( {sign} {modifier.priceDelta} ); } function ruleSummary(group: ModifierGroup): string { const parts: string[] = []; if (group.selectionType === 'SINGLE') { parts.push('Pick one'); } else if (group.max != null) { if (group.min === 0) parts.push(`Up to ${group.max}`); else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`); else parts.push(`${group.min}–${group.max}`); } else if (group.min > 0) { parts.push(`At least ${group.min}`); } return parts.length ? `(${parts.join(', ')})` : ''; }