'use client'; /** * — reference React component for storefront authors * (PRD §8.4). * * Drop-in renderer for a single modifier group (toppings, sauce, bread type, …). * Storefront authors are encouraged to copy this file and tailor the styling / * markup to their site — the data contract is what matters, not the markup. * * Behavior pinned by the contract: * - SINGLE → renders as a radio group, max picks = 1 * - MULTIPLE → renders as checkboxes, 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. */ import type { Modifier, ModifierGroup } from 'brainerce'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; // 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; const renderOption = (modifier: Modifier) => { const isChecked = value.includes(modifier.id); const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax; const isDisabled = disabled || !modifier.available || isAtCap; return (
  • {modifier.description && (

    {modifier.description}

    )}
  • ); }; return (
    {group.name} {group.required && *} {ruleSummary(group)} {group.description && (

    {group.description}

    )} {showFreeCounter && (

    {usedFreeSlots} of {group.freeQuantity} free

    )} {isSingle ? ( onChange([v])} disabled={disabled} asChild >
      {sortedModifiers.map(renderOption)}
    ) : ( )}
    ); } /** * 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(', ')})` : ''; }