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,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>