create-brainerce-store 1.50.5 → 1.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +146 -100
  3. package/messages/en.json +36 -1
  4. package/messages/he.json +36 -1
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.claude/commands/design.md +60 -0
  7. package/templates/nextjs/base/.env.local.ejs +5 -0
  8. package/templates/nextjs/base/.eslintrc.json +57 -3
  9. package/templates/nextjs/base/AGENTS.md +41 -0
  10. package/templates/nextjs/base/AI-GUIDE.md +143 -0
  11. package/templates/nextjs/base/CLAUDE.md +52 -0
  12. package/templates/nextjs/base/TRANSLATIONS.md +1 -1
  13. package/templates/nextjs/base/next.config.ts +2 -2
  14. package/templates/nextjs/base/package.json.ejs +52 -39
  15. package/templates/nextjs/base/src/app/account/page.tsx +5 -5
  16. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
  17. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
  18. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
  19. package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
  20. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
  21. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
  22. package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
  23. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
  24. package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
  25. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
  26. package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
  27. package/templates/nextjs/base/src/app/globals.css +36 -30
  28. package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
  29. package/templates/nextjs/base/src/app/login/page.tsx +4 -4
  30. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
  31. package/templates/nextjs/base/src/app/page.tsx +4 -4
  32. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
  33. package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
  34. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
  35. package/templates/nextjs/base/src/app/products/page.tsx +4 -623
  36. package/templates/nextjs/base/src/app/register/page.tsx +4 -4
  37. package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
  38. package/templates/nextjs/base/src/app/sitemap.ts +1 -1
  39. package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
  40. package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
  41. package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
  42. package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
  43. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
  44. package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
  45. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
  46. package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
  47. package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
  48. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
  49. package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
  50. package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
  51. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
  52. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
  53. package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
  54. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
  55. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +20 -16
  56. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
  57. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
  58. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
  59. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
  60. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
  61. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
  62. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
  63. package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
  64. package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
  65. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
  66. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
  67. package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
  68. package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
  69. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
  70. package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
  71. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
  72. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
  73. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
  74. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
  75. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
  76. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
  77. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
  78. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
  79. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
  80. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
  81. package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
  82. package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
  83. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
  84. package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
  85. package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
  86. package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
  87. package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
  88. package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
  89. package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +22 -16
  90. package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +8 -14
  91. package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +28 -39
  92. package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +7 -5
  93. package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +13 -28
  94. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -0
  95. package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +14 -25
  96. package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +6 -7
  97. package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +95 -103
  98. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
  99. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
  100. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -0
  101. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
  102. package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +116 -125
  103. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -0
  104. package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +5 -20
  105. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -0
  106. package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
  107. package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +144 -164
  108. package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +152 -182
  109. package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +457 -478
  110. package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +18 -22
  111. package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +21 -33
  112. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -0
  113. package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +5 -4
  114. package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +238 -271
  115. package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +455 -631
  116. package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
  117. package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -0
  118. package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +102 -107
  119. package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +46 -45
  120. package/templates/nextjs/base/src/{components/reviews/reviews-section.tsx → ui/product/reviews-section.tsx.ejs} +38 -15
  121. package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +61 -63
  122. package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
  123. package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
  124. package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
  125. package/templates/nextjs/base/tailwind.config.ts +69 -30
  126. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +251 -0
  127. package/templates/nextjs/designs/atelier/globals.css +396 -0
  128. package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
  129. package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
  130. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -0
  131. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
  132. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
  133. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
  134. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
  135. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -0
  136. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
  137. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
  138. package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -0
  139. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -0
  140. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
  141. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
  142. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
  143. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
  144. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
  145. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
  146. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
  147. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
  148. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
  149. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
  150. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
  151. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
  152. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
  153. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
  154. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
  155. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
  156. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
  157. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
  158. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
  159. package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -0
  160. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -0
  161. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
  162. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
  163. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
  164. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
  165. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
  166. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
  167. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
  168. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -0
  169. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
  170. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
  171. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
  172. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
  173. package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -0
  174. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
  175. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
  176. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
  177. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
  178. package/templates/nextjs/themes/luxury/globals.css +405 -399
  179. package/templates/nextjs/themes/minimal/globals.css +36 -30
  180. package/templates/nextjs/themes/playful/globals.css +406 -400
  181. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -0
  182. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -0
  183. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -0
  184. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -0
  185. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -0
  186. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
  187. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -0
  188. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -0
  189. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -0
  190. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
  191. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
  192. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
  193. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
  194. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
  195. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
  196. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
  197. package/templates/nextjs/{base/src/components → ui-canvas}/layout/language-switcher.tsx.ejs +76 -70
  198. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -0
  199. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
  200. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
  201. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -0
  202. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -0
  203. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -0
  204. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -0
  205. package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -0
  206. package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
  207. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
  208. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
  209. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
  210. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -0
  211. package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -0
  212. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -0
  213. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -0
  214. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -0
  215. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -0
  216. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -0
  217. package/templates/nextjs/base/src/app/home-client.tsx +0 -98
  218. package/templates/nextjs/base/src/components/content/faq-section.tsx.ejs +0 -103
  219. package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +0 -238
  220. /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
  221. /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
  222. /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
  223. /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
  224. /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
  225. /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
  226. /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
  227. /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
  228. /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
  229. /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
  230. /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
@@ -1,238 +0,0 @@
1
- 'use client';
2
-
3
- /**
4
- * <ModifierGroupSelector> — reference React component for storefront authors
5
- * (PRD §8.4).
6
- *
7
- * Drop-in renderer for a single modifier group (toppings, sauce, bread type, …).
8
- * Storefront authors are encouraged to copy this file and tailor the styling /
9
- * markup to their site — the data contract is what matters, not the markup.
10
- *
11
- * Behavior pinned by the contract:
12
- * - SINGLE → renders as <input type="radio">, max picks = 1
13
- * - MULTIPLE → renders as <input type="checkbox">, picks bounded by min..max
14
- * - effectiveMax === 0 → group is hidden entirely (variant-disable convention)
15
- * - available === false → modifier is disabled with "Sold out" badge
16
- * - free counter shows "{used} of {freeQuantity} free" when freeQuantity > 0
17
- * - defaultModifierIds + isDefault drive first-render checked state
18
- * - client-side guards mirror min/max (UX); the SERVER is authoritative — wire
19
- * `MODIFIER_VALIDATION_FAILED` errors back to the user when add-to-cart fails
20
- *
21
- * The price-delta side: this component does NOT compute totals. The server
22
- * runs the free-allocation policy and returns the line's `unitPrice` /
23
- * `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
24
- */
25
-
26
- import type { Modifier, ModifierGroup, ModifierSelection } from 'brainerce';
27
-
28
- interface ModifierGroupSelectorProps {
29
- /**
30
- * Effective group as returned by `GET /products/:id` (overrides already
31
- * applied — `min`, `max`, `freeQuantity`, etc. are the values the server
32
- * will validate against for the active variant).
33
- */
34
- group: ModifierGroup;
35
- /** Currently-selected modifier IDs in click-order. */
36
- value: string[];
37
- onChange: (next: string[]) => void;
38
- /** Disable all controls (e.g., while add-to-cart is in flight). */
39
- disabled?: boolean;
40
- }
41
-
42
- export function ModifierGroupSelector({
43
- group,
44
- value,
45
- onChange,
46
- disabled = false,
47
- }: ModifierGroupSelectorProps) {
48
- // PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
49
- if (group.max === 0) return null;
50
-
51
- const isSingle = group.selectionType === 'SINGLE';
52
- // Client-side bound — server is authoritative.
53
- const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
54
-
55
- const handleToggle = (modifierId: string, checked: boolean) => {
56
- if (disabled) return;
57
-
58
- if (isSingle) {
59
- // Radio behavior: replace the selection.
60
- onChange(checked ? [modifierId] : []);
61
- return;
62
- }
63
-
64
- if (checked) {
65
- if (value.length >= effectiveMax) return; // would exceed max — ignore the click
66
- if (value.includes(modifierId)) return;
67
- onChange([...value, modifierId]);
68
- } else {
69
- onChange(value.filter((id) => id !== modifierId));
70
- }
71
- };
72
-
73
- const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
74
-
75
- // Counter: how many of the picked items are within the "free" window.
76
- const usedFreeSlots = Math.min(value.length, group.freeQuantity);
77
- const showFreeCounter = group.freeQuantity > 0;
78
-
79
- return (
80
- <fieldset className="modifier-group">
81
- <legend className="modifier-group__legend">
82
- <span className="modifier-group__name">{group.name}</span>
83
- {group.required && <span className="modifier-group__required"> *</span>}
84
- <span className="modifier-group__rules">{ruleSummary(group)}</span>
85
- </legend>
86
-
87
- {group.description && <p className="modifier-group__description">{group.description}</p>}
88
-
89
- {showFreeCounter && (
90
- <p className="modifier-group__counter" aria-live="polite">
91
- {usedFreeSlots} of {group.freeQuantity} free
92
- </p>
93
- )}
94
-
95
- <ul className="modifier-group__options">
96
- {sortedModifiers.map((modifier) => {
97
- const isChecked = value.includes(modifier.id);
98
- const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
99
- const isDisabled = disabled || !modifier.available || isAtCap;
100
-
101
- return (
102
- <li key={modifier.id} className="modifier-option">
103
- <label className="modifier-option__label">
104
- <input
105
- type={isSingle ? 'radio' : 'checkbox'}
106
- name={`modifier-group-${group.id}`}
107
- value={modifier.id}
108
- checked={isChecked}
109
- disabled={isDisabled}
110
- onChange={(e) => handleToggle(modifier.id, e.target.checked)}
111
- className="modifier-option__input"
112
- />
113
- <span className="modifier-option__name">{modifier.name}</span>
114
- <ModifierPriceLabel modifier={modifier} />
115
- {!modifier.available && (
116
- <span
117
- className="modifier-option__badge modifier-option__badge--soldout"
118
- aria-label="Sold out"
119
- >
120
- Sold out
121
- </span>
122
- )}
123
- </label>
124
- {modifier.description && (
125
- <p className="modifier-option__description">{modifier.description}</p>
126
- )}
127
- </li>
128
- );
129
- })}
130
- </ul>
131
- </fieldset>
132
- );
133
- }
134
-
135
- /**
136
- * Renders the modifier's price contribution next to its name.
137
- *
138
- * Negative deltas display with a leading minus sign (downsell modifiers per
139
- * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
140
- * affordance for a free option.
141
- */
142
- function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
143
- const delta = parseFloat(modifier.priceDelta);
144
- if (Number.isNaN(delta) || delta === 0) return null;
145
- const sign = delta > 0 ? '+' : '';
146
- return (
147
- <span className="modifier-option__price">
148
- {sign}
149
- {modifier.priceDelta}
150
- </span>
151
- );
152
- }
153
-
154
- function ruleSummary(group: ModifierGroup): string {
155
- const parts: string[] = [];
156
- if (group.selectionType === 'SINGLE') {
157
- parts.push('Pick one');
158
- } else if (group.max != null) {
159
- if (group.min === 0) parts.push(`Up to ${group.max}`);
160
- else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
161
- else parts.push(`${group.min}–${group.max}`);
162
- } else if (group.min > 0) {
163
- parts.push(`At least ${group.min}`);
164
- }
165
- return parts.length ? `(${parts.join(', ')})` : '';
166
- }
167
-
168
- /**
169
- * Lightweight client-side validation that mirrors the server's checks. Use
170
- * this to gate the add-to-cart button — but always treat the server's 400
171
- * (`MODIFIER_VALIDATION_FAILED`) as the authoritative answer.
172
- */
173
- export function validateSelections(
174
- groups: ModifierGroup[],
175
- selections: Record<string, string[]>
176
- ): string | null {
177
- for (const group of groups) {
178
- if (group.max === 0) continue; // disabled-for-variant — skip
179
- const picks = selections[group.id] ?? [];
180
- if (group.required && picks.length === 0) {
181
- return `${group.name} is required`;
182
- }
183
- if (picks.length < group.min) {
184
- return `Pick at least ${group.min} from ${group.name}`;
185
- }
186
- if (group.max != null && picks.length > group.max) {
187
- return `Pick at most ${group.max} from ${group.name}`;
188
- }
189
- if (group.selectionType === 'SINGLE' && picks.length > 1) {
190
- return `Only one option allowed in ${group.name}`;
191
- }
192
- }
193
- return null;
194
- }
195
-
196
- /**
197
- * Adapter: convert the local Record<groupId, modifierIds> shape used by this
198
- * component into the wire-format `ModifierSelection[]` array the SDK expects
199
- * on `addToCart` / `updateCartItem`.
200
- */
201
- export function toModifierSelections(
202
- groups: ModifierGroup[],
203
- selections: Record<string, string[]>
204
- ): ModifierSelection[] {
205
- const out: ModifierSelection[] = [];
206
- for (const group of groups) {
207
- if (group.max === 0) continue;
208
- const picks = selections[group.id] ?? [];
209
- if (picks.length === 0) continue;
210
- out.push({ modifierGroupId: group.id, modifierIds: picks });
211
- }
212
- return out;
213
- }
214
-
215
- /**
216
- * Build the initial selection map from the group's defaults — call once on
217
- * first render for each group. Honors `defaultModifierIds` first (per-attach
218
- * defaults), falling back to per-modifier `isDefault` flags. Sold-out
219
- * modifiers are filtered out so the user doesn't start with an invalid pick.
220
- */
221
- export function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
222
- const out: Record<string, string[]> = {};
223
- for (const group of groups) {
224
- if (group.max === 0) continue;
225
- const fromDefaults = group.defaultModifierIds ?? [];
226
- const fromIsDefault = group.modifiers
227
- .filter((m) => m.isDefault && m.available)
228
- .map((m) => m.id);
229
- const merged =
230
- fromDefaults.length > 0
231
- ? fromDefaults.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
232
- : fromIsDefault;
233
- // Cap by max for SINGLE groups — defaults can't violate selection rules.
234
- const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
235
- if (capped.length > 0) out[group.id] = capped;
236
- }
237
- return out;
238
- }