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,206 +1,206 @@
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 a radio group, max picks = 1
13
- * - MULTIPLE → renders as checkboxes, 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 } from 'brainerce';
27
- import { Badge } from '@/components/ui/badge';
28
- import { Checkbox } from '@/components/ui/checkbox';
29
- import { Label } from '@/components/ui/label';
30
- import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
31
-
32
- // The pure selection logic (defaults, validation, wire-format adapter) lives
33
- // in core (zero markup) so that `core/hooks/use-product-page` never imports
34
- // from `ui/`. Re-exported here for backward compatibility with existing imports.
35
- export {
36
- buildInitialSelections,
37
- toModifierSelections,
38
- validateSelections,
39
- } from '@/core/lib/product-options';
40
-
41
- interface ModifierGroupSelectorProps {
42
- /**
43
- * Effective group as returned by `GET /products/:id` (overrides already
44
- * applied — `min`, `max`, `freeQuantity`, etc. are the values the server
45
- * will validate against for the active variant).
46
- */
47
- group: ModifierGroup;
48
- /** Currently-selected modifier IDs in click-order. */
49
- value: string[];
50
- onChange: (next: string[]) => void;
51
- /** Disable all controls (e.g., while add-to-cart is in flight). */
52
- disabled?: boolean;
53
- }
54
-
55
- export function ModifierGroupSelector({
56
- group,
57
- value,
58
- onChange,
59
- disabled = false,
60
- }: ModifierGroupSelectorProps) {
61
- // PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
62
- if (group.max === 0) return null;
63
-
64
- const isSingle = group.selectionType === 'SINGLE';
65
- // Client-side bound — server is authoritative.
66
- const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
67
-
68
- const handleToggle = (modifierId: string, checked: boolean) => {
69
- if (disabled) return;
70
-
71
- if (isSingle) {
72
- // Radio behavior: replace the selection.
73
- onChange(checked ? [modifierId] : []);
74
- return;
75
- }
76
-
77
- if (checked) {
78
- if (value.length >= effectiveMax) return; // would exceed max — ignore the click
79
- if (value.includes(modifierId)) return;
80
- onChange([...value, modifierId]);
81
- } else {
82
- onChange(value.filter((id) => id !== modifierId));
83
- }
84
- };
85
-
86
- const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
87
-
88
- // Counter: how many of the picked items are within the "free" window.
89
- const usedFreeSlots = Math.min(value.length, group.freeQuantity);
90
- const showFreeCounter = group.freeQuantity > 0;
91
-
92
- const renderOption = (modifier: Modifier) => {
93
- const isChecked = value.includes(modifier.id);
94
- const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
95
- const isDisabled = disabled || !modifier.available || isAtCap;
96
-
97
- return (
98
- <li key={modifier.id} className="modifier-option">
99
- <Label className="modifier-option__label flex cursor-pointer items-center gap-2 font-normal">
100
- {isSingle ? (
101
- <RadioGroupItem
102
- value={modifier.id}
103
- disabled={isDisabled}
104
- className="modifier-option__input"
105
- />
106
- ) : (
107
- <Checkbox
108
- checked={isChecked}
109
- disabled={isDisabled}
110
- onCheckedChange={(checked) => handleToggle(modifier.id, checked === true)}
111
- className="modifier-option__input"
112
- />
113
- )}
114
- <span className="modifier-option__name text-sm">{modifier.name}</span>
115
- <ModifierPriceLabel modifier={modifier} />
116
- {!modifier.available && (
117
- <Badge
118
- variant="secondary"
119
- className="modifier-option__badge modifier-option__badge--soldout font-normal"
120
- aria-label="Sold out"
121
- >
122
- Sold out
123
- </Badge>
124
- )}
125
- </Label>
126
- {modifier.description && (
127
- <p className="modifier-option__description text-muted-foreground ms-6 text-xs">
128
- {modifier.description}
129
- </p>
130
- )}
131
- </li>
132
- );
133
- };
134
-
135
- return (
136
- <fieldset className="modifier-group">
137
- <legend className="modifier-group__legend text-sm">
138
- <span className="modifier-group__name font-medium">{group.name}</span>
139
- {group.required && <span className="modifier-group__required text-destructive"> *</span>}
140
- <span className="modifier-group__rules text-muted-foreground"> {ruleSummary(group)}</span>
141
- </legend>
142
-
143
- {group.description && (
144
- <p className="modifier-group__description text-muted-foreground text-xs">
145
- {group.description}
146
- </p>
147
- )}
148
-
149
- {showFreeCounter && (
150
- <p className="modifier-group__counter text-muted-foreground text-xs" aria-live="polite">
151
- {usedFreeSlots} of {group.freeQuantity} free
152
- </p>
153
- )}
154
-
155
- {isSingle ? (
156
- <RadioGroup
157
- value={value[0] ?? ''}
158
- onValueChange={(v) => onChange([v])}
159
- disabled={disabled}
160
- asChild
161
- >
162
- <ul className="modifier-group__options mt-2 gap-2">
163
- {sortedModifiers.map(renderOption)}
164
- </ul>
165
- </RadioGroup>
166
- ) : (
167
- <ul className="modifier-group__options mt-2 grid gap-2">
168
- {sortedModifiers.map(renderOption)}
169
- </ul>
170
- )}
171
- </fieldset>
172
- );
173
- }
174
-
175
- /**
176
- * Renders the modifier's price contribution next to its name.
177
- *
178
- * Negative deltas display with a leading minus sign (downsell modifiers per
179
- * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
180
- * affordance for a free option.
181
- */
182
- function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
183
- const delta = parseFloat(modifier.priceDelta);
184
- if (Number.isNaN(delta) || delta === 0) return null;
185
- const sign = delta > 0 ? '+' : '';
186
- return (
187
- <span className="modifier-option__price text-muted-foreground text-xs">
188
- {sign}
189
- {modifier.priceDelta}
190
- </span>
191
- );
192
- }
193
-
194
- function ruleSummary(group: ModifierGroup): string {
195
- const parts: string[] = [];
196
- if (group.selectionType === 'SINGLE') {
197
- parts.push('Pick one');
198
- } else if (group.max != null) {
199
- if (group.min === 0) parts.push(`Up to ${group.max}`);
200
- else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
201
- else parts.push(`${group.min}–${group.max}`);
202
- } else if (group.min > 0) {
203
- parts.push(`At least ${group.min}`);
204
- }
205
- return parts.length ? `(${parts.join(', ')})` : '';
206
- }
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 a radio group, max picks = 1
13
+ * - MULTIPLE → renders as checkboxes, 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 } from 'brainerce';
27
+ import { Badge } from '@/components/ui/badge';
28
+ import { Checkbox } from '@/components/ui/checkbox';
29
+ import { Label } from '@/components/ui/label';
30
+ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
31
+
32
+ // The pure selection logic (defaults, validation, wire-format adapter) lives
33
+ // in core (zero markup) so that `core/hooks/use-product-page` never imports
34
+ // from `ui/`. Re-exported here for backward compatibility with existing imports.
35
+ export {
36
+ buildInitialSelections,
37
+ toModifierSelections,
38
+ validateSelections,
39
+ } from '@/core/lib/product-options';
40
+
41
+ interface ModifierGroupSelectorProps {
42
+ /**
43
+ * Effective group as returned by `GET /products/:id` (overrides already
44
+ * applied — `min`, `max`, `freeQuantity`, etc. are the values the server
45
+ * will validate against for the active variant).
46
+ */
47
+ group: ModifierGroup;
48
+ /** Currently-selected modifier IDs in click-order. */
49
+ value: string[];
50
+ onChange: (next: string[]) => void;
51
+ /** Disable all controls (e.g., while add-to-cart is in flight). */
52
+ disabled?: boolean;
53
+ }
54
+
55
+ export function ModifierGroupSelector({
56
+ group,
57
+ value,
58
+ onChange,
59
+ disabled = false,
60
+ }: ModifierGroupSelectorProps) {
61
+ // PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
62
+ if (group.max === 0) return null;
63
+
64
+ const isSingle = group.selectionType === 'SINGLE';
65
+ // Client-side bound — server is authoritative.
66
+ const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
67
+
68
+ const handleToggle = (modifierId: string, checked: boolean) => {
69
+ if (disabled) return;
70
+
71
+ if (isSingle) {
72
+ // Radio behavior: replace the selection.
73
+ onChange(checked ? [modifierId] : []);
74
+ return;
75
+ }
76
+
77
+ if (checked) {
78
+ if (value.length >= effectiveMax) return; // would exceed max — ignore the click
79
+ if (value.includes(modifierId)) return;
80
+ onChange([...value, modifierId]);
81
+ } else {
82
+ onChange(value.filter((id) => id !== modifierId));
83
+ }
84
+ };
85
+
86
+ const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
87
+
88
+ // Counter: how many of the picked items are within the "free" window.
89
+ const usedFreeSlots = Math.min(value.length, group.freeQuantity);
90
+ const showFreeCounter = group.freeQuantity > 0;
91
+
92
+ const renderOption = (modifier: Modifier) => {
93
+ const isChecked = value.includes(modifier.id);
94
+ const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
95
+ const isDisabled = disabled || !modifier.available || isAtCap;
96
+
97
+ return (
98
+ <li key={modifier.id} className="modifier-option">
99
+ <Label className="modifier-option__label flex cursor-pointer items-center gap-2 font-normal">
100
+ {isSingle ? (
101
+ <RadioGroupItem
102
+ value={modifier.id}
103
+ disabled={isDisabled}
104
+ className="modifier-option__input"
105
+ />
106
+ ) : (
107
+ <Checkbox
108
+ checked={isChecked}
109
+ disabled={isDisabled}
110
+ onCheckedChange={(checked) => handleToggle(modifier.id, checked === true)}
111
+ className="modifier-option__input"
112
+ />
113
+ )}
114
+ <span className="modifier-option__name text-sm">{modifier.name}</span>
115
+ <ModifierPriceLabel modifier={modifier} />
116
+ {!modifier.available && (
117
+ <Badge
118
+ variant="secondary"
119
+ className="modifier-option__badge modifier-option__badge--soldout font-normal"
120
+ aria-label="Sold out"
121
+ >
122
+ Sold out
123
+ </Badge>
124
+ )}
125
+ </Label>
126
+ {modifier.description && (
127
+ <p className="modifier-option__description text-muted-foreground ms-6 text-xs">
128
+ {modifier.description}
129
+ </p>
130
+ )}
131
+ </li>
132
+ );
133
+ };
134
+
135
+ return (
136
+ <fieldset className="modifier-group">
137
+ <legend className="modifier-group__legend text-sm">
138
+ <span className="modifier-group__name font-medium">{group.name}</span>
139
+ {group.required && <span className="modifier-group__required text-destructive"> *</span>}
140
+ <span className="modifier-group__rules text-muted-foreground"> {ruleSummary(group)}</span>
141
+ </legend>
142
+
143
+ {group.description && (
144
+ <p className="modifier-group__description text-muted-foreground text-xs">
145
+ {group.description}
146
+ </p>
147
+ )}
148
+
149
+ {showFreeCounter && (
150
+ <p className="modifier-group__counter text-muted-foreground text-xs" aria-live="polite">
151
+ {usedFreeSlots} of {group.freeQuantity} free
152
+ </p>
153
+ )}
154
+
155
+ {isSingle ? (
156
+ <RadioGroup
157
+ value={value[0] ?? ''}
158
+ onValueChange={(v) => onChange([v])}
159
+ disabled={disabled}
160
+ asChild
161
+ >
162
+ <ul className="modifier-group__options mt-2 gap-2">
163
+ {sortedModifiers.map(renderOption)}
164
+ </ul>
165
+ </RadioGroup>
166
+ ) : (
167
+ <ul className="modifier-group__options mt-2 grid gap-2">
168
+ {sortedModifiers.map(renderOption)}
169
+ </ul>
170
+ )}
171
+ </fieldset>
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Renders the modifier's price contribution next to its name.
177
+ *
178
+ * Negative deltas display with a leading minus sign (downsell modifiers per
179
+ * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
180
+ * affordance for a free option.
181
+ */
182
+ function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
183
+ const delta = parseFloat(modifier.priceDelta);
184
+ if (Number.isNaN(delta) || delta === 0) return null;
185
+ const sign = delta > 0 ? '+' : '';
186
+ return (
187
+ <span className="modifier-option__price text-muted-foreground text-xs">
188
+ {sign}
189
+ {modifier.priceDelta}
190
+ </span>
191
+ );
192
+ }
193
+
194
+ function ruleSummary(group: ModifierGroup): string {
195
+ const parts: string[] = [];
196
+ if (group.selectionType === 'SINGLE') {
197
+ parts.push('Pick one');
198
+ } else if (group.max != null) {
199
+ if (group.min === 0) parts.push(`Up to ${group.max}`);
200
+ else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
201
+ else parts.push(`${group.min}–${group.max}`);
202
+ } else if (group.min > 0) {
203
+ parts.push(`At least ${group.min}`);
204
+ }
205
+ return parts.length ? `(${parts.join(', ')})` : '';
206
+ }
@@ -1,62 +1,62 @@
1
- 'use client';
2
-
3
- import { formatPrice } from 'brainerce';
4
- import { Badge } from '@/components/ui/badge';
5
- import { useCurrency } from '@/core/lib/use-currency';
6
- import { cn } from '@/core/lib/utils';
7
-
8
- interface PriceDisplayProps {
9
- price: string | number;
10
- salePrice?: string | number | null;
11
- currency?: string;
12
- className?: string;
13
- size?: 'sm' | 'md' | 'lg';
14
- }
15
-
16
- const sizeClasses = {
17
- sm: 'text-sm',
18
- md: 'text-base',
19
- lg: 'text-xl font-semibold',
20
- };
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 className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
41
- {isOnSale ? (
42
- <>
43
- <span className="text-destructive font-medium">
44
- {formatPrice(sale!, { currency: currencyCode }) as string}
45
- </span>
46
- <span className="text-muted-foreground text-[0.85em] line-through">
47
- {formatPrice(basePrice, { currency: currencyCode }) as string}
48
- </span>
49
- {discountPercent > 0 && (
50
- <Badge variant="destructive" className="rounded px-1.5 py-0.5 font-medium">
51
- -{discountPercent}%
52
- </Badge>
53
- )}
54
- </>
55
- ) : (
56
- <span className="text-foreground font-medium">
57
- {formatPrice(basePrice, { currency: currencyCode }) as string}
58
- </span>
59
- )}
60
- </span>
61
- );
62
- }
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { useCurrency } from '@/core/lib/use-currency';
6
+ import { cn } from '@/core/lib/utils';
7
+
8
+ interface PriceDisplayProps {
9
+ price: string | number;
10
+ salePrice?: string | number | null;
11
+ currency?: string;
12
+ className?: string;
13
+ size?: 'sm' | 'md' | 'lg';
14
+ }
15
+
16
+ const sizeClasses = {
17
+ sm: 'text-sm',
18
+ md: 'text-base',
19
+ lg: 'text-xl font-semibold',
20
+ };
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 className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
41
+ {isOnSale ? (
42
+ <>
43
+ <span className="text-destructive font-medium">
44
+ {formatPrice(sale!, { currency: currencyCode }) as string}
45
+ </span>
46
+ <span className="text-muted-foreground text-[0.85em] line-through">
47
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
48
+ </span>
49
+ {discountPercent > 0 && (
50
+ <Badge variant="destructive" className="rounded px-1.5 py-0.5 font-medium">
51
+ -{discountPercent}%
52
+ </Badge>
53
+ )}
54
+ </>
55
+ ) : (
56
+ <span className="text-foreground font-medium">
57
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
58
+ </span>
59
+ )}
60
+ </span>
61
+ );
62
+ }
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
15
15
  import { Card } from '@/components/ui/card';
16
16
  import { useCart } from '@/core/providers/store-provider';
17
17
  import { useCurrency } from '@/core/lib/use-currency';
18
+ import { trackAddToCart } from '@/core/lib/tracking';
18
19
  import { cn } from '@/core/lib/utils';
19
20
 
20
21
  interface ProductCardProps {
@@ -36,8 +37,7 @@ function pickDisplayPrice(
36
37
  fallbackCurrency: string
37
38
  ): { price: number | undefined; salePrice: number | null; currency: string } {
38
39
  if (product.displayPrice != null && product.displayCurrency) {
39
- const sale =
40
- product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
40
+ const sale = product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
41
41
  return {
42
42
  // `displayPrice` is the base price converted to the region currency —
43
43
  // it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
@@ -123,6 +123,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
123
123
  const client = getClient();
124
124
  await client.smartAddToCart({ productId: product.id, quantity: 1 });
125
125
  await refreshCart();
126
+ // Quick-add from a grid card is a real add_to_cart — without this the
127
+ // ad platforms only ever see adds made from a product page. Reported in
128
+ // the DISPLAYED currency (region-converted when the storefront passed a
129
+ // regionId), because that is what the shopper is actually being charged.
130
+ trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency);
126
131
  setAdded(true);
127
132
  setTimeout(() => setAdded(false), 2000);
128
133
  } catch (err) {