create-brainerce-store 1.62.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 +5 -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 -612
  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,193 +1,193 @@
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
-
24
- import type { Modifier, ModifierGroup } from 'brainerce';
25
- import { useTranslations } from '@/core/lib/translations';
26
- import { cn } from '@/core/lib/utils';
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
- const t = useTranslations('productDetail');
58
-
59
- // PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
60
- if (group.max === 0) return null;
61
-
62
- const isSingle = group.selectionType === 'SINGLE';
63
- // Client-side bound — server is authoritative.
64
- const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
65
-
66
- const handleToggle = (modifierId: string, checked: boolean) => {
67
- if (disabled) return;
68
-
69
- if (isSingle) {
70
- // Radio behavior: replace the selection.
71
- onChange(checked ? [modifierId] : []);
72
- return;
73
- }
74
-
75
- if (checked) {
76
- if (value.length >= effectiveMax) return; // would exceed max — ignore the click
77
- if (value.includes(modifierId)) return;
78
- onChange([...value, modifierId]);
79
- } else {
80
- onChange(value.filter((id) => id !== modifierId));
81
- }
82
- };
83
-
84
- const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
85
-
86
- // Counter: how many of the picked items are within the "free" window.
87
- const usedFreeSlots = Math.min(value.length, group.freeQuantity);
88
- const showFreeCounter = group.freeQuantity > 0;
89
-
90
- return (
91
- <fieldset className="rounded-xl border p-5">
92
- <legend className="px-1.5 text-sm font-semibold text-foreground">
93
- <span>{group.name}</span>
94
- {group.required && (
95
- <span aria-hidden="true" className="text-destructive">
96
- {' '}
97
- *
98
- </span>
99
- )}
100
- <span className="font-normal text-muted-foreground"> {ruleSummary(group, t)}</span>
101
- </legend>
102
-
103
- {group.description && (
104
- <p className="mb-3 text-sm text-muted-foreground">{group.description}</p>
105
- )}
106
-
107
- {showFreeCounter && (
108
- <p aria-live="polite" className="mb-3 inline-flex rounded-full bg-secondary px-3 py-1 text-xs font-semibold">
109
- {t('freeCounter')
110
- .replace('{used}', String(usedFreeSlots))
111
- .replace('{free}', String(group.freeQuantity))}
112
- </p>
113
- )}
114
-
115
- <ul className="space-y-2">
116
- {sortedModifiers.map((modifier) => {
117
- const isChecked = value.includes(modifier.id);
118
- const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
119
- const isDisabled = disabled || !modifier.available || isAtCap;
120
-
121
- return (
122
- <li key={modifier.id}>
123
- <label
124
- className={cn(
125
- 'flex cursor-pointer items-center gap-3 rounded-lg border px-3.5 py-2.5 transition-colors',
126
- isChecked ? 'border-primary bg-secondary/60' : 'border-border',
127
- isDisabled ? 'cursor-default opacity-50' : 'hover:border-foreground/40'
128
- )}
129
- >
130
- <input
131
- type={isSingle ? 'radio' : 'checkbox'}
132
- name={`modifier-group-${group.id}`}
133
- value={modifier.id}
134
- checked={isChecked}
135
- disabled={isDisabled}
136
- onChange={(e) => handleToggle(modifier.id, e.target.checked)}
137
- className="h-4 w-4 accent-primary"
138
- />
139
- <span className="flex-1 text-sm font-medium text-foreground">{modifier.name}</span>
140
- <ModifierPriceLabel modifier={modifier} />
141
- {!modifier.available && (
142
- <span aria-label={t('soldOut')} className="badge-destructive">
143
- {t('soldOut')}
144
- </span>
145
- )}
146
- </label>
147
- {modifier.description && (
148
- <p className="mt-1 ps-7 text-xs text-muted-foreground">{modifier.description}</p>
149
- )}
150
- </li>
151
- );
152
- })}
153
- </ul>
154
- </fieldset>
155
- );
156
- }
157
-
158
- /**
159
- * Renders the modifier's price contribution next to its name.
160
- *
161
- * Negative deltas display with a leading minus sign (downsell modifiers per
162
- * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
163
- * affordance for a free option.
164
- */
165
- function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
166
- const delta = parseFloat(modifier.priceDelta);
167
- if (Number.isNaN(delta) || delta === 0) return null;
168
- const sign = delta > 0 ? '+' : '';
169
- return (
170
- <span className="text-sm font-semibold text-muted-foreground">
171
- {sign}
172
- {modifier.priceDelta}
173
- </span>
174
- );
175
- }
176
-
177
- function ruleSummary(group: ModifierGroup, t: (key: string) => string): string {
178
- const parts: string[] = [];
179
- if (group.selectionType === 'SINGLE') {
180
- parts.push(t('pickOne'));
181
- } else if (group.max != null) {
182
- if (group.min === 0) parts.push(t('upTo').replace('{max}', String(group.max)));
183
- else if (group.min === group.max)
184
- parts.push(t('pickExactly').replace('{min}', String(group.min)));
185
- else
186
- parts.push(
187
- t('pickRange').replace('{min}', String(group.min)).replace('{max}', String(group.max))
188
- );
189
- } else if (group.min > 0) {
190
- parts.push(t('atLeast').replace('{min}', String(group.min)));
191
- }
192
- return parts.length ? `(${parts.join(', ')})` : '';
193
- }
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
+
24
+ import type { Modifier, ModifierGroup } from 'brainerce';
25
+ import { useTranslations } from '@/core/lib/translations';
26
+ import { cn } from '@/core/lib/utils';
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
+ const t = useTranslations('productDetail');
58
+
59
+ // PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
60
+ if (group.max === 0) return null;
61
+
62
+ const isSingle = group.selectionType === 'SINGLE';
63
+ // Client-side bound — server is authoritative.
64
+ const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
65
+
66
+ const handleToggle = (modifierId: string, checked: boolean) => {
67
+ if (disabled) return;
68
+
69
+ if (isSingle) {
70
+ // Radio behavior: replace the selection.
71
+ onChange(checked ? [modifierId] : []);
72
+ return;
73
+ }
74
+
75
+ if (checked) {
76
+ if (value.length >= effectiveMax) return; // would exceed max — ignore the click
77
+ if (value.includes(modifierId)) return;
78
+ onChange([...value, modifierId]);
79
+ } else {
80
+ onChange(value.filter((id) => id !== modifierId));
81
+ }
82
+ };
83
+
84
+ const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
85
+
86
+ // Counter: how many of the picked items are within the "free" window.
87
+ const usedFreeSlots = Math.min(value.length, group.freeQuantity);
88
+ const showFreeCounter = group.freeQuantity > 0;
89
+
90
+ return (
91
+ <fieldset className="rounded-xl border p-5">
92
+ <legend className="px-1.5 text-sm font-semibold text-foreground">
93
+ <span>{group.name}</span>
94
+ {group.required && (
95
+ <span aria-hidden="true" className="text-destructive">
96
+ {' '}
97
+ *
98
+ </span>
99
+ )}
100
+ <span className="font-normal text-muted-foreground"> {ruleSummary(group, t)}</span>
101
+ </legend>
102
+
103
+ {group.description && (
104
+ <p className="mb-3 text-sm text-muted-foreground">{group.description}</p>
105
+ )}
106
+
107
+ {showFreeCounter && (
108
+ <p aria-live="polite" className="mb-3 inline-flex rounded-full bg-secondary px-3 py-1 text-xs font-semibold">
109
+ {t('freeCounter')
110
+ .replace('{used}', String(usedFreeSlots))
111
+ .replace('{free}', String(group.freeQuantity))}
112
+ </p>
113
+ )}
114
+
115
+ <ul className="space-y-2">
116
+ {sortedModifiers.map((modifier) => {
117
+ const isChecked = value.includes(modifier.id);
118
+ const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
119
+ const isDisabled = disabled || !modifier.available || isAtCap;
120
+
121
+ return (
122
+ <li key={modifier.id}>
123
+ <label
124
+ className={cn(
125
+ 'flex cursor-pointer items-center gap-3 rounded-lg border px-3.5 py-2.5 transition-colors',
126
+ isChecked ? 'border-primary bg-secondary/60' : 'border-border',
127
+ isDisabled ? 'cursor-default opacity-50' : 'hover:border-foreground/40'
128
+ )}
129
+ >
130
+ <input
131
+ type={isSingle ? 'radio' : 'checkbox'}
132
+ name={`modifier-group-${group.id}`}
133
+ value={modifier.id}
134
+ checked={isChecked}
135
+ disabled={isDisabled}
136
+ onChange={(e) => handleToggle(modifier.id, e.target.checked)}
137
+ className="h-4 w-4 accent-primary"
138
+ />
139
+ <span className="flex-1 text-sm font-medium text-foreground">{modifier.name}</span>
140
+ <ModifierPriceLabel modifier={modifier} />
141
+ {!modifier.available && (
142
+ <span aria-label={t('soldOut')} className="badge-destructive">
143
+ {t('soldOut')}
144
+ </span>
145
+ )}
146
+ </label>
147
+ {modifier.description && (
148
+ <p className="mt-1 ps-7 text-xs text-muted-foreground">{modifier.description}</p>
149
+ )}
150
+ </li>
151
+ );
152
+ })}
153
+ </ul>
154
+ </fieldset>
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Renders the modifier's price contribution next to its name.
160
+ *
161
+ * Negative deltas display with a leading minus sign (downsell modifiers per
162
+ * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
163
+ * affordance for a free option.
164
+ */
165
+ function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
166
+ const delta = parseFloat(modifier.priceDelta);
167
+ if (Number.isNaN(delta) || delta === 0) return null;
168
+ const sign = delta > 0 ? '+' : '';
169
+ return (
170
+ <span className="text-sm font-semibold text-muted-foreground">
171
+ {sign}
172
+ {modifier.priceDelta}
173
+ </span>
174
+ );
175
+ }
176
+
177
+ function ruleSummary(group: ModifierGroup, t: (key: string) => string): string {
178
+ const parts: string[] = [];
179
+ if (group.selectionType === 'SINGLE') {
180
+ parts.push(t('pickOne'));
181
+ } else if (group.max != null) {
182
+ if (group.min === 0) parts.push(t('upTo').replace('{max}', String(group.max)));
183
+ else if (group.min === group.max)
184
+ parts.push(t('pickExactly').replace('{min}', String(group.min)));
185
+ else
186
+ parts.push(
187
+ t('pickRange').replace('{min}', String(group.min)).replace('{max}', String(group.max))
188
+ );
189
+ } else if (group.min > 0) {
190
+ parts.push(t('atLeast').replace('{min}', String(group.min)));
191
+ }
192
+ return parts.length ? `(${parts.join(', ')})` : '';
193
+ }
@@ -1,69 +1,69 @@
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
- const CURRENT: Record<NonNullable<PriceDisplayProps['size']>, string> = {
16
- sm: 'text-sm font-semibold',
17
- md: 'text-base font-semibold',
18
- lg: 'text-2xl font-semibold',
19
- };
20
- const STRUCK: Record<NonNullable<PriceDisplayProps['size']>, string> = {
21
- sm: 'text-xs',
22
- md: 'text-sm',
23
- lg: 'text-base',
24
- };
25
-
26
- /**
27
- * Price renderer used by cards, product page and recommendations; computes
28
- * sale state + discount % from props (currency via useCurrency()).
29
- * Never hardcode currency symbols — formatPrice handles them.
30
- */
31
- export function PriceDisplay({
32
- price,
33
- salePrice,
34
- currency,
35
- className,
36
- size = 'md',
37
- }: PriceDisplayProps) {
38
- const currencyCode = useCurrency(currency);
39
-
40
- const basePrice = typeof price === 'string' ? parseFloat(price) : price;
41
- const sale =
42
- salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
43
- const isOnSale = sale !== null && sale < basePrice;
44
-
45
- const discountPercent =
46
- isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
47
-
48
- return (
49
- <span data-size={size} className={cn('inline-flex flex-wrap items-baseline gap-2', className)}>
50
- {isOnSale ? (
51
- <>
52
- <span className={cn(CURRENT[size], 'text-foreground')}>
53
- {formatPrice(sale!, { currency: currencyCode }) as string}
54
- </span>
55
- <s className={cn(STRUCK[size], 'text-muted-foreground')}>
56
- {formatPrice(basePrice, { currency: currencyCode }) as string}
57
- </s>
58
- {discountPercent > 0 && (
59
- <span className="badge-destructive self-center">-{discountPercent}%</span>
60
- )}
61
- </>
62
- ) : (
63
- <span className={cn(CURRENT[size], 'text-foreground')}>
64
- {formatPrice(basePrice, { currency: currencyCode }) as string}
65
- </span>
66
- )}
67
- </span>
68
- );
69
- }
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
+ const CURRENT: Record<NonNullable<PriceDisplayProps['size']>, string> = {
16
+ sm: 'text-sm font-semibold',
17
+ md: 'text-base font-semibold',
18
+ lg: 'text-2xl font-semibold',
19
+ };
20
+ const STRUCK: Record<NonNullable<PriceDisplayProps['size']>, string> = {
21
+ sm: 'text-xs',
22
+ md: 'text-sm',
23
+ lg: 'text-base',
24
+ };
25
+
26
+ /**
27
+ * Price renderer used by cards, product page and recommendations; computes
28
+ * sale state + discount % from props (currency via useCurrency()).
29
+ * Never hardcode currency symbols — formatPrice handles them.
30
+ */
31
+ export function PriceDisplay({
32
+ price,
33
+ salePrice,
34
+ currency,
35
+ className,
36
+ size = 'md',
37
+ }: PriceDisplayProps) {
38
+ const currencyCode = useCurrency(currency);
39
+
40
+ const basePrice = typeof price === 'string' ? parseFloat(price) : price;
41
+ const sale =
42
+ salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
43
+ const isOnSale = sale !== null && sale < basePrice;
44
+
45
+ const discountPercent =
46
+ isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
47
+
48
+ return (
49
+ <span data-size={size} className={cn('inline-flex flex-wrap items-baseline gap-2', className)}>
50
+ {isOnSale ? (
51
+ <>
52
+ <span className={cn(CURRENT[size], 'text-foreground')}>
53
+ {formatPrice(sale!, { currency: currencyCode }) as string}
54
+ </span>
55
+ <s className={cn(STRUCK[size], 'text-muted-foreground')}>
56
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
57
+ </s>
58
+ {discountPercent > 0 && (
59
+ <span className="badge-destructive self-center">-{discountPercent}%</span>
60
+ )}
61
+ </>
62
+ ) : (
63
+ <span className={cn(CURRENT[size], 'text-foreground')}>
64
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
65
+ </span>
66
+ )}
67
+ </span>
68
+ );
69
+ }
@@ -13,6 +13,7 @@ import { useCart } from '@/core/providers/store-provider';
13
13
  import { flyToCart } from '@/ui/shared/fly-to-cart';
14
14
  import { openCartDrawer } from '@/ui/cart/cart-drawer';
15
15
  import { useCurrency } from '@/core/lib/use-currency';
16
+ import { trackAddToCart } from '@/core/lib/tracking';
16
17
  import { cn } from '@/core/lib/utils';
17
18
  import { IconBag, IconCheck, IconDownload } from '@/ui/shared/icons';
18
19
 
@@ -35,8 +36,7 @@ function pickDisplayPrice(
35
36
  fallbackCurrency: string
36
37
  ): { price: number | undefined; salePrice: number | null; currency: string } {
37
38
  if (product.displayPrice != null && product.displayCurrency) {
38
- const sale =
39
- product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
39
+ const sale = product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
40
40
  return {
41
41
  // `displayPrice` is the base price converted to the region currency —
42
42
  // it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
@@ -75,7 +75,7 @@ function VariantPriceRange({ product }: { product: Product }) {
75
75
  if (isNaN(min) || isNaN(max)) return null;
76
76
 
77
77
  return (
78
- <span className="text-base font-semibold text-foreground">
78
+ <span className="text-foreground text-base font-semibold">
79
79
  {min === max
80
80
  ? (formatPrice(min, { currency }) as string)
81
81
  : `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
@@ -127,6 +127,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
127
127
  await client.smartAddToCart({ productId: product.id, quantity: 1 });
128
128
  await refreshCart();
129
129
  openCartDrawer();
130
+ // Quick-add from a grid card is a real add_to_cart — without this the
131
+ // ad platforms only ever see adds made from a product page. Reported in
132
+ // the DISPLAYED currency (region-converted when the storefront passed a
133
+ // regionId), because that is what the shopper is actually being charged.
134
+ trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency);
130
135
  setAdded(true);
131
136
  setTimeout(() => setAdded(false), 2000);
132
137
  } catch (err) {
@@ -138,15 +143,12 @@ export function ProductCard({ product, className }: ProductCardProps) {
138
143
 
139
144
  return (
140
145
  <article
141
- className={cn(
142
- 'group card card-hover relative flex flex-col overflow-hidden',
143
- className
144
- )}
146
+ className={cn('card card-hover group relative flex flex-col overflow-hidden', className)}
145
147
  >
146
148
  <figure className="relative">
147
149
  <Link href={`/products/${slug}`} className="block" tabIndex={-1} aria-hidden="true">
148
150
  {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
149
- <div ref={imageWellRef} className="relative aspect-square overflow-hidden bg-secondary">
151
+ <div ref={imageWellRef} className="bg-secondary relative aspect-square overflow-hidden">
150
152
  {imageUrl ? (
151
153
  <>
152
154
  <Image
@@ -192,7 +194,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
192
194
  <div className="flex flex-1 flex-col gap-1.5 p-4">
193
195
  {/* Categories */}
194
196
  {product.categories && product.categories.length > 0 && (
195
- <ul className="flex flex-wrap gap-1 text-xs text-muted-foreground">
197
+ <ul className="text-muted-foreground flex flex-wrap gap-1 text-xs">
196
198
  {product.categories.slice(0, 2).map((cat, idx) => (
197
199
  <li key={cat.id}>
198
200
  {idx > 0 && <span aria-hidden="true">· </span>}
@@ -203,10 +205,10 @@ export function ProductCard({ product, className }: ProductCardProps) {
203
205
  )}
204
206
 
205
207
  {/* Name — the card-covering link */}
206
- <h3 className="font-sans text-[15px] font-semibold leading-snug text-foreground">
208
+ <h3 className="text-foreground font-sans text-[15px] font-semibold leading-snug">
207
209
  <Link
208
210
  href={`/products/${slug}`}
209
- className="after:absolute after:inset-0 after:content-[''] hover:text-primary"
211
+ className="hover:text-primary after:absolute after:inset-0 after:content-['']"
210
212
  >
211
213
  {product.name}
212
214
  </Link>