create-brainerce-store 1.62.0 → 1.64.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 (203) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +71 -71
  3. package/dist/index.js +26 -3
  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/agents.md/route.ts +87 -0
  15. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +308 -262
  16. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
  17. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +70 -0
  18. package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
  19. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +29 -3
  20. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
  21. package/templates/nextjs/base/src/app/contact/layout.tsx.ejs +14 -0
  22. package/templates/nextjs/base/src/app/globals.css +36 -36
  23. package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
  24. package/templates/nextjs/base/src/app/layout.tsx.ejs +30 -0
  25. package/templates/nextjs/base/src/app/llms.txt/route.ts +28 -5
  26. package/templates/nextjs/base/src/app/not-found.tsx.ejs +35 -0
  27. package/templates/nextjs/base/src/app/opengraph-image.tsx +109 -0
  28. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
  29. package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
  30. package/templates/nextjs/base/src/app/page.tsx +64 -64
  31. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  32. package/templates/nextjs/base/src/app/products/page.tsx +19 -19
  33. package/templates/nextjs/base/src/app/robots.ts +73 -14
  34. package/templates/nextjs/base/src/app/sitemap.ts +89 -66
  35. package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
  36. package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
  37. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
  38. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
  39. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
  40. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
  41. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
  42. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
  43. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
  44. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
  45. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
  46. package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
  47. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
  48. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
  49. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +23 -131
  50. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
  51. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
  52. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
  53. package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
  54. package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
  55. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
  56. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
  57. package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
  58. package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
  59. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
  60. package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
  61. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
  62. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
  63. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
  64. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
  65. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
  66. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
  67. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
  68. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
  69. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
  70. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
  71. package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
  72. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
  73. package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
  74. package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
  75. package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
  76. package/templates/nextjs/base/src/core/lib/store-info.ts +17 -0
  77. package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
  78. package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
  79. package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
  80. package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
  81. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
  82. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
  83. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
  84. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
  85. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
  86. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
  87. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
  88. package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
  89. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
  90. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +22 -20
  91. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
  92. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  93. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
  94. package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
  95. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
  96. package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
  97. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
  98. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
  99. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
  100. package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
  101. package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
  102. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
  103. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
  104. package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
  105. package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
  106. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
  107. package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
  108. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
  109. package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
  110. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
  111. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
  112. package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
  113. package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
  114. package/templates/nextjs/base/tailwind.config.ts +69 -69
  115. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +30 -0
  116. package/templates/nextjs/designs/atelier/globals.css +402 -402
  117. package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
  118. package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
  119. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
  120. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
  121. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
  122. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
  123. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
  124. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
  125. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
  126. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
  127. package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
  128. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
  129. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
  130. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
  131. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
  132. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
  133. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
  134. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
  135. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
  136. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
  137. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
  138. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
  139. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
  140. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
  141. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
  142. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
  143. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
  144. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
  145. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
  146. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
  147. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
  148. package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
  149. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
  150. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
  151. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
  152. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
  153. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
  154. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
  155. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
  156. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
  157. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
  158. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
  159. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
  160. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
  161. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
  162. package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
  163. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
  164. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
  165. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
  166. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
  167. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
  168. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
  169. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +29 -27
  170. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
  171. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
  172. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
  173. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
  174. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
  175. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
  176. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +21 -19
  177. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
  178. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
  179. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
  180. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
  181. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
  182. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
  183. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
  184. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
  185. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
  186. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
  187. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
  188. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
  189. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
  190. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
  191. package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
  192. package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
  193. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
  194. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
  195. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
  196. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
  197. package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
  198. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
  199. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
  200. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
  201. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
  202. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
  203. 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) {