create-brainerce-store 1.61.0 → 1.63.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 (196) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +71 -71
  3. package/dist/index.js +10 -2
  4. package/messages/en.json +502 -502
  5. package/messages/he.json +502 -502
  6. package/package.json +1 -1
  7. package/templates/nextjs/base/.claude/commands/design.md +60 -60
  8. package/templates/nextjs/base/.env.local.ejs +24 -24
  9. package/templates/nextjs/base/.eslintrc.json +57 -57
  10. package/templates/nextjs/base/.mcp.json +8 -8
  11. package/templates/nextjs/base/AI-GUIDE.md +174 -174
  12. package/templates/nextjs/base/TRANSLATIONS.md +200 -200
  13. package/templates/nextjs/base/package.json.ejs +52 -52
  14. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +262 -262
  15. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
  16. package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
  17. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +106 -106
  18. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
  19. package/templates/nextjs/base/src/app/globals.css +36 -36
  20. package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
  21. package/templates/nextjs/base/src/app/layout.tsx.ejs +10 -0
  22. package/templates/nextjs/base/src/app/llms.txt/route.ts +44 -44
  23. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
  24. package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
  25. package/templates/nextjs/base/src/app/page.tsx +64 -64
  26. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +175 -175
  27. package/templates/nextjs/base/src/app/products/page.tsx +19 -19
  28. package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
  29. package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
  30. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -564
  31. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
  32. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
  33. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
  34. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
  35. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
  36. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
  37. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
  38. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
  39. package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
  40. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
  41. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
  42. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +179 -179
  43. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
  44. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
  45. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
  46. package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
  47. package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
  48. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
  49. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
  50. package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
  51. package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
  52. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
  53. package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
  54. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
  55. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
  56. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
  57. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
  58. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
  59. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
  60. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
  61. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
  62. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
  63. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
  64. package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
  65. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
  66. package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
  67. package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
  68. package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
  69. package/templates/nextjs/base/src/core/lib/store-info.ts +9 -0
  70. package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
  71. package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
  72. package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
  73. package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
  74. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
  75. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
  76. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
  77. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
  78. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
  79. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
  80. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
  81. package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
  82. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
  83. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -20
  84. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
  85. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  86. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
  87. package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
  88. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
  89. package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
  90. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
  91. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
  92. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
  93. package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
  94. package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
  95. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
  96. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
  97. package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
  98. package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
  99. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
  100. package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
  101. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
  102. package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
  103. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
  104. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
  105. package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
  106. package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
  107. package/templates/nextjs/base/tailwind.config.ts +69 -69
  108. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +10 -0
  109. package/templates/nextjs/designs/atelier/globals.css +402 -402
  110. package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
  111. package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
  112. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
  113. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
  114. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
  115. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
  116. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
  117. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
  118. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
  119. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
  120. package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
  121. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
  122. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
  123. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
  124. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
  125. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
  126. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
  127. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
  128. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
  129. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
  130. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
  131. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
  132. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
  133. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
  134. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
  135. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
  136. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
  137. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
  138. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
  139. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
  140. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
  141. package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
  142. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
  143. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
  144. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
  145. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
  146. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
  147. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
  148. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
  149. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
  150. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
  151. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
  152. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
  153. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
  154. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
  155. package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
  156. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
  157. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
  158. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
  159. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
  160. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
  161. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
  162. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -27
  163. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
  164. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
  165. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
  166. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
  167. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
  168. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
  169. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -19
  170. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
  171. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
  172. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
  173. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
  174. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
  175. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
  176. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
  177. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
  178. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
  179. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
  180. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
  181. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
  182. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
  183. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
  184. package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
  185. package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
  186. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
  187. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
  188. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
  189. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
  190. package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
  191. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
  192. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
  193. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
  194. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
  195. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
  196. package/templates/nextjs/base/tsconfig.tsbuildinfo +0 -1
@@ -1,165 +1,165 @@
1
- 'use client';
2
-
3
- /**
4
- * <ModifierGroupSelector> — bare-skeleton renderer for a single modifier
5
- * group (toppings, sauce, bread type, …) per PRD §8.4.
6
- *
7
- * DESIGN ME — modifier group on the product page; the group + selections
8
- * come from useProductPage() via props. The data contract is what matters:
9
- *
10
- * - SINGLE → renders as <input type="radio">, max picks = 1
11
- * - MULTIPLE → renders as <input type="checkbox">, picks bounded by min..max
12
- * - effectiveMax === 0 → group is hidden entirely (variant-disable convention)
13
- * - available === false → modifier is disabled with "Sold out" badge
14
- * - free counter shows "{used} of {freeQuantity} free" when freeQuantity > 0
15
- * - defaultModifierIds + isDefault drive first-render checked state
16
- * - client-side guards mirror min/max (UX); the SERVER is authoritative — wire
17
- * `MODIFIER_VALIDATION_FAILED` errors back to the user when add-to-cart fails
18
- *
19
- * The price-delta side: this component does NOT compute totals. The server
20
- * runs the free-allocation policy and returns the line's `unitPrice` /
21
- * `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
22
- *
23
- * 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.
24
- */
25
-
26
- import type { Modifier, ModifierGroup } from 'brainerce';
27
-
28
- // The pure selection logic (defaults, validation, wire-format adapter) lives
29
- // in core (zero markup) so that `core/hooks/use-product-page` never imports
30
- // from `ui/`. Re-exported here for backward compatibility with existing imports.
31
- export {
32
- buildInitialSelections,
33
- toModifierSelections,
34
- validateSelections,
35
- } from '@/core/lib/product-options';
36
-
37
- interface ModifierGroupSelectorProps {
38
- /**
39
- * Effective group as returned by `GET /products/:id` (overrides already
40
- * applied — `min`, `max`, `freeQuantity`, etc. are the values the server
41
- * will validate against for the active variant).
42
- */
43
- group: ModifierGroup;
44
- /** Currently-selected modifier IDs in click-order. */
45
- value: string[];
46
- onChange: (next: string[]) => void;
47
- /** Disable all controls (e.g., while add-to-cart is in flight). */
48
- disabled?: boolean;
49
- }
50
-
51
- export function ModifierGroupSelector({
52
- group,
53
- value,
54
- onChange,
55
- disabled = false,
56
- }: ModifierGroupSelectorProps) {
57
- // PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
58
- if (group.max === 0) return null;
59
-
60
- const isSingle = group.selectionType === 'SINGLE';
61
- // Client-side bound — server is authoritative.
62
- const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
63
-
64
- const handleToggle = (modifierId: string, checked: boolean) => {
65
- if (disabled) return;
66
-
67
- if (isSingle) {
68
- // Radio behavior: replace the selection.
69
- onChange(checked ? [modifierId] : []);
70
- return;
71
- }
72
-
73
- if (checked) {
74
- if (value.length >= effectiveMax) return; // would exceed max — ignore the click
75
- if (value.includes(modifierId)) return;
76
- onChange([...value, modifierId]);
77
- } else {
78
- onChange(value.filter((id) => id !== modifierId));
79
- }
80
- };
81
-
82
- const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
83
-
84
- // Counter: how many of the picked items are within the "free" window.
85
- const usedFreeSlots = Math.min(value.length, group.freeQuantity);
86
- const showFreeCounter = group.freeQuantity > 0;
87
-
88
- return (
89
- <fieldset>
90
- <legend>
91
- <span>{group.name}</span>
92
- {group.required && <span aria-hidden="true"> *</span>}
93
- <span> {ruleSummary(group)}</span>
94
- </legend>
95
-
96
- {group.description && <p>{group.description}</p>}
97
-
98
- {showFreeCounter && (
99
- <p aria-live="polite">
100
- {usedFreeSlots} of {group.freeQuantity} free
101
- </p>
102
- )}
103
-
104
- <ul>
105
- {sortedModifiers.map((modifier) => {
106
- const isChecked = value.includes(modifier.id);
107
- const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
108
- const isDisabled = disabled || !modifier.available || isAtCap;
109
-
110
- return (
111
- <li key={modifier.id}>
112
- <label className="inline-flex items-center gap-2">
113
- <input
114
- type={isSingle ? 'radio' : 'checkbox'}
115
- name={`modifier-group-${group.id}`}
116
- value={modifier.id}
117
- checked={isChecked}
118
- disabled={isDisabled}
119
- onChange={(e) => handleToggle(modifier.id, e.target.checked)}
120
- />
121
- <span>{modifier.name}</span>
122
- <ModifierPriceLabel modifier={modifier} />
123
- {!modifier.available && <span aria-label="Sold out">Sold out</span>}
124
- </label>
125
- {modifier.description && <p>{modifier.description}</p>}
126
- </li>
127
- );
128
- })}
129
- </ul>
130
- </fieldset>
131
- );
132
- }
133
-
134
- /**
135
- * Renders the modifier's price contribution next to its name.
136
- *
137
- * Negative deltas display with a leading minus sign (downsell modifiers per
138
- * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
139
- * affordance for a free option.
140
- */
141
- function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
142
- const delta = parseFloat(modifier.priceDelta);
143
- if (Number.isNaN(delta) || delta === 0) return null;
144
- const sign = delta > 0 ? '+' : '';
145
- return (
146
- <span>
147
- {sign}
148
- {modifier.priceDelta}
149
- </span>
150
- );
151
- }
152
-
153
- function ruleSummary(group: ModifierGroup): string {
154
- const parts: string[] = [];
155
- if (group.selectionType === 'SINGLE') {
156
- parts.push('Pick one');
157
- } else if (group.max != null) {
158
- if (group.min === 0) parts.push(`Up to ${group.max}`);
159
- else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
160
- else parts.push(`${group.min}–${group.max}`);
161
- } else if (group.min > 0) {
162
- parts.push(`At least ${group.min}`);
163
- }
164
- return parts.length ? `(${parts.join(', ')})` : '';
165
- }
1
+ 'use client';
2
+
3
+ /**
4
+ * <ModifierGroupSelector> — bare-skeleton renderer for a single modifier
5
+ * group (toppings, sauce, bread type, …) per PRD §8.4.
6
+ *
7
+ * DESIGN ME — modifier group on the product page; the group + selections
8
+ * come from useProductPage() via props. The data contract is what matters:
9
+ *
10
+ * - SINGLE → renders as <input type="radio">, max picks = 1
11
+ * - MULTIPLE → renders as <input type="checkbox">, picks bounded by min..max
12
+ * - effectiveMax === 0 → group is hidden entirely (variant-disable convention)
13
+ * - available === false → modifier is disabled with "Sold out" badge
14
+ * - free counter shows "{used} of {freeQuantity} free" when freeQuantity > 0
15
+ * - defaultModifierIds + isDefault drive first-render checked state
16
+ * - client-side guards mirror min/max (UX); the SERVER is authoritative — wire
17
+ * `MODIFIER_VALIDATION_FAILED` errors back to the user when add-to-cart fails
18
+ *
19
+ * The price-delta side: this component does NOT compute totals. The server
20
+ * runs the free-allocation policy and returns the line's `unitPrice` /
21
+ * `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
22
+ *
23
+ * 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.
24
+ */
25
+
26
+ import type { Modifier, ModifierGroup } from 'brainerce';
27
+
28
+ // The pure selection logic (defaults, validation, wire-format adapter) lives
29
+ // in core (zero markup) so that `core/hooks/use-product-page` never imports
30
+ // from `ui/`. Re-exported here for backward compatibility with existing imports.
31
+ export {
32
+ buildInitialSelections,
33
+ toModifierSelections,
34
+ validateSelections,
35
+ } from '@/core/lib/product-options';
36
+
37
+ interface ModifierGroupSelectorProps {
38
+ /**
39
+ * Effective group as returned by `GET /products/:id` (overrides already
40
+ * applied — `min`, `max`, `freeQuantity`, etc. are the values the server
41
+ * will validate against for the active variant).
42
+ */
43
+ group: ModifierGroup;
44
+ /** Currently-selected modifier IDs in click-order. */
45
+ value: string[];
46
+ onChange: (next: string[]) => void;
47
+ /** Disable all controls (e.g., while add-to-cart is in flight). */
48
+ disabled?: boolean;
49
+ }
50
+
51
+ export function ModifierGroupSelector({
52
+ group,
53
+ value,
54
+ onChange,
55
+ disabled = false,
56
+ }: ModifierGroupSelectorProps) {
57
+ // PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
58
+ if (group.max === 0) return null;
59
+
60
+ const isSingle = group.selectionType === 'SINGLE';
61
+ // Client-side bound — server is authoritative.
62
+ const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
63
+
64
+ const handleToggle = (modifierId: string, checked: boolean) => {
65
+ if (disabled) return;
66
+
67
+ if (isSingle) {
68
+ // Radio behavior: replace the selection.
69
+ onChange(checked ? [modifierId] : []);
70
+ return;
71
+ }
72
+
73
+ if (checked) {
74
+ if (value.length >= effectiveMax) return; // would exceed max — ignore the click
75
+ if (value.includes(modifierId)) return;
76
+ onChange([...value, modifierId]);
77
+ } else {
78
+ onChange(value.filter((id) => id !== modifierId));
79
+ }
80
+ };
81
+
82
+ const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
83
+
84
+ // Counter: how many of the picked items are within the "free" window.
85
+ const usedFreeSlots = Math.min(value.length, group.freeQuantity);
86
+ const showFreeCounter = group.freeQuantity > 0;
87
+
88
+ return (
89
+ <fieldset>
90
+ <legend>
91
+ <span>{group.name}</span>
92
+ {group.required && <span aria-hidden="true"> *</span>}
93
+ <span> {ruleSummary(group)}</span>
94
+ </legend>
95
+
96
+ {group.description && <p>{group.description}</p>}
97
+
98
+ {showFreeCounter && (
99
+ <p aria-live="polite">
100
+ {usedFreeSlots} of {group.freeQuantity} free
101
+ </p>
102
+ )}
103
+
104
+ <ul>
105
+ {sortedModifiers.map((modifier) => {
106
+ const isChecked = value.includes(modifier.id);
107
+ const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
108
+ const isDisabled = disabled || !modifier.available || isAtCap;
109
+
110
+ return (
111
+ <li key={modifier.id}>
112
+ <label className="inline-flex items-center gap-2">
113
+ <input
114
+ type={isSingle ? 'radio' : 'checkbox'}
115
+ name={`modifier-group-${group.id}`}
116
+ value={modifier.id}
117
+ checked={isChecked}
118
+ disabled={isDisabled}
119
+ onChange={(e) => handleToggle(modifier.id, e.target.checked)}
120
+ />
121
+ <span>{modifier.name}</span>
122
+ <ModifierPriceLabel modifier={modifier} />
123
+ {!modifier.available && <span aria-label="Sold out">Sold out</span>}
124
+ </label>
125
+ {modifier.description && <p>{modifier.description}</p>}
126
+ </li>
127
+ );
128
+ })}
129
+ </ul>
130
+ </fieldset>
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Renders the modifier's price contribution next to its name.
136
+ *
137
+ * Negative deltas display with a leading minus sign (downsell modifiers per
138
+ * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
139
+ * affordance for a free option.
140
+ */
141
+ function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
142
+ const delta = parseFloat(modifier.priceDelta);
143
+ if (Number.isNaN(delta) || delta === 0) return null;
144
+ const sign = delta > 0 ? '+' : '';
145
+ return (
146
+ <span>
147
+ {sign}
148
+ {modifier.priceDelta}
149
+ </span>
150
+ );
151
+ }
152
+
153
+ function ruleSummary(group: ModifierGroup): string {
154
+ const parts: string[] = [];
155
+ if (group.selectionType === 'SINGLE') {
156
+ parts.push('Pick one');
157
+ } else if (group.max != null) {
158
+ if (group.min === 0) parts.push(`Up to ${group.max}`);
159
+ else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
160
+ else parts.push(`${group.min}–${group.max}`);
161
+ } else if (group.min > 0) {
162
+ parts.push(`At least ${group.min}`);
163
+ }
164
+ return parts.length ? `(${parts.join(', ')})` : '';
165
+ }
@@ -1,52 +1,52 @@
1
- 'use client';
2
-
3
- import { formatPrice } from 'brainerce';
4
- import { useCurrency } from '@/core/lib/use-currency';
5
- import { cn } from '@/core/lib/utils';
6
-
7
- interface PriceDisplayProps {
8
- price: string | number;
9
- salePrice?: string | number | null;
10
- currency?: string;
11
- className?: string;
12
- size?: 'sm' | 'md' | 'lg';
13
- }
14
-
15
- /**
16
- * DESIGN ME — price renderer used by cards, product page and recommendations;
17
- * computes sale state + discount % from props (currency via useCurrency()).
18
- * Never hardcode currency symbols — formatPrice handles them.
19
- *
20
- * 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.
21
- */
22
- export function PriceDisplay({
23
- price,
24
- salePrice,
25
- currency,
26
- className,
27
- size = 'md',
28
- }: PriceDisplayProps) {
29
- const currencyCode = useCurrency(currency);
30
-
31
- const basePrice = typeof price === 'string' ? parseFloat(price) : price;
32
- const sale =
33
- salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
34
- const isOnSale = sale !== null && sale < basePrice;
35
-
36
- const discountPercent =
37
- isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
38
-
39
- return (
40
- <span data-size={size} className={cn('inline-flex items-center gap-2', className)}>
41
- {isOnSale ? (
42
- <>
43
- <span>{formatPrice(sale!, { currency: currencyCode }) as string}</span>
44
- <s>{formatPrice(basePrice, { currency: currencyCode }) as string}</s>
45
- {discountPercent > 0 && <span>-{discountPercent}%</span>}
46
- </>
47
- ) : (
48
- <span>{formatPrice(basePrice, { currency: currencyCode }) as string}</span>
49
- )}
50
- </span>
51
- );
52
- }
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { useCurrency } from '@/core/lib/use-currency';
5
+ import { cn } from '@/core/lib/utils';
6
+
7
+ interface PriceDisplayProps {
8
+ price: string | number;
9
+ salePrice?: string | number | null;
10
+ currency?: string;
11
+ className?: string;
12
+ size?: 'sm' | 'md' | 'lg';
13
+ }
14
+
15
+ /**
16
+ * DESIGN ME — price renderer used by cards, product page and recommendations;
17
+ * computes sale state + discount % from props (currency via useCurrency()).
18
+ * Never hardcode currency symbols — formatPrice handles them.
19
+ *
20
+ * 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.
21
+ */
22
+ export function PriceDisplay({
23
+ price,
24
+ salePrice,
25
+ currency,
26
+ className,
27
+ size = 'md',
28
+ }: PriceDisplayProps) {
29
+ const currencyCode = useCurrency(currency);
30
+
31
+ const basePrice = typeof price === 'string' ? parseFloat(price) : price;
32
+ const sale =
33
+ salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
34
+ const isOnSale = sale !== null && sale < basePrice;
35
+
36
+ const discountPercent =
37
+ isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
38
+
39
+ return (
40
+ <span data-size={size} className={cn('inline-flex items-center gap-2', className)}>
41
+ {isOnSale ? (
42
+ <>
43
+ <span>{formatPrice(sale!, { currency: currencyCode }) as string}</span>
44
+ <s>{formatPrice(basePrice, { currency: currencyCode }) as string}</s>
45
+ {discountPercent > 0 && <span>-{discountPercent}%</span>}
46
+ </>
47
+ ) : (
48
+ <span>{formatPrice(basePrice, { currency: currencyCode }) as string}</span>
49
+ )}
50
+ </span>
51
+ );
52
+ }
@@ -11,6 +11,7 @@ import { StockBadge } from '@/ui/product/stock-badge';
11
11
  import { DiscountBadge } from '@/ui/product/discount-badge';
12
12
  import { useCart } from '@/core/providers/store-provider';
13
13
  import { useCurrency } from '@/core/lib/use-currency';
14
+ import { trackAddToCart } from '@/core/lib/tracking';
14
15
  import { cn } from '@/core/lib/utils';
15
16
 
16
17
  interface ProductCardProps {
@@ -32,8 +33,7 @@ function pickDisplayPrice(
32
33
  fallbackCurrency: string
33
34
  ): { price: number | undefined; salePrice: number | null; currency: string } {
34
35
  if (product.displayPrice != null && product.displayCurrency) {
35
- const sale =
36
- product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
36
+ const sale = product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
37
37
  return {
38
38
  // `displayPrice` is the base price converted to the region currency —
39
39
  // it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
@@ -119,6 +119,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
119
119
  const client = getClient();
120
120
  await client.smartAddToCart({ productId: product.id, quantity: 1 });
121
121
  await refreshCart();
122
+ // Quick-add from a grid card is a real add_to_cart — without this the
123
+ // ad platforms only ever see adds made from a product page. Reported in
124
+ // the DISPLAYED currency (region-converted when the storefront passed a
125
+ // regionId), because that is what the shopper is actually being charged.
126
+ trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency);
122
127
  setAdded(true);
123
128
  setTimeout(() => setAdded(false), 2000);
124
129
  } catch (err) {