create-brainerce-store 1.50.5 → 1.51.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 (186) hide show
  1. package/dist/index.js +171 -66
  2. package/messages/en.json +4 -1
  3. package/messages/he.json +4 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.claude/commands/design.md +51 -0
  6. package/templates/nextjs/base/.env.local.ejs +5 -0
  7. package/templates/nextjs/base/.eslintrc.json +57 -3
  8. package/templates/nextjs/base/AGENTS.md +41 -0
  9. package/templates/nextjs/base/AI-GUIDE.md +94 -0
  10. package/templates/nextjs/base/CLAUDE.md +42 -0
  11. package/templates/nextjs/base/TRANSLATIONS.md +1 -1
  12. package/templates/nextjs/base/next.config.ts +2 -2
  13. package/templates/nextjs/base/src/app/account/page.tsx +5 -5
  14. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
  15. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
  16. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
  17. package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
  18. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
  19. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
  20. package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
  21. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
  22. package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
  23. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
  24. package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
  25. package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
  26. package/templates/nextjs/base/src/app/login/page.tsx +4 -4
  27. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
  28. package/templates/nextjs/base/src/app/page.tsx +4 -4
  29. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
  30. package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
  31. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
  32. package/templates/nextjs/base/src/app/products/page.tsx +4 -623
  33. package/templates/nextjs/base/src/app/register/page.tsx +4 -4
  34. package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
  35. package/templates/nextjs/base/src/app/sitemap.ts +1 -1
  36. package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
  37. package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
  38. package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
  39. package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
  40. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
  41. package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
  42. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
  43. package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
  44. package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
  45. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
  46. package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
  47. package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
  48. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
  49. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
  50. package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
  51. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
  52. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +6 -6
  53. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
  54. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
  55. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
  56. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
  57. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
  58. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
  59. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
  60. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
  61. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
  62. package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
  63. package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
  64. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
  65. package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
  66. package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
  67. package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
  68. package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
  69. package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
  70. package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +5 -5
  71. package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +6 -6
  72. package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +1 -1
  73. package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +4 -4
  74. package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +5 -5
  75. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +149 -0
  76. package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +4 -4
  77. package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +4 -4
  78. package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +2 -2
  79. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
  80. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
  81. package/templates/nextjs/base/src/ui/home/hero-section.tsx +29 -0
  82. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
  83. package/templates/nextjs/base/src/{components/content → ui/layout}/faq-section.tsx.ejs +2 -2
  84. package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +3 -3
  85. package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
  86. package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +1 -1
  87. package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +18 -51
  88. package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +1 -1
  89. package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +6 -6
  90. package/templates/nextjs/base/src/{components/products → ui/product}/modifier-group-selector.tsx +10 -73
  91. package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +2 -2
  92. package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +10 -10
  93. package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +45 -206
  94. package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
  95. package/templates/nextjs/base/src/ui/product/product-listing.tsx +429 -0
  96. package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +4 -4
  97. package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +2 -2
  98. package/templates/nextjs/base/src/{components/reviews → ui/product}/reviews-section.tsx +1 -1
  99. package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +2 -2
  100. package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
  101. package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
  102. package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
  103. package/templates/nextjs/base/tailwind.config.ts +12 -0
  104. package/templates/nextjs/designs/passepartout/app-overlay/layout.tsx.ejs +253 -0
  105. package/templates/nextjs/designs/passepartout/globals.css +412 -0
  106. package/templates/nextjs/designs/passepartout/messages-patch/en.json +87 -0
  107. package/templates/nextjs/designs/passepartout/messages-patch/he.json +87 -0
  108. package/templates/nextjs/designs/passepartout/ui/cart/cart-bundle-offer.tsx +111 -0
  109. package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +39 -0
  110. package/templates/nextjs/designs/passepartout/ui/cart/cart-upgrade-banner.tsx +141 -0
  111. package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +149 -0
  112. package/templates/nextjs/designs/passepartout/ui/cart/free-shipping-bar.tsx +62 -0
  113. package/templates/nextjs/designs/passepartout/ui/cart/reservation-countdown.tsx +103 -0
  114. package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +401 -0
  115. package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +139 -0
  116. package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +45 -0
  117. package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +103 -0
  118. package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +159 -0
  119. package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +155 -0
  120. package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +60 -0
  121. package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +34 -0
  122. package/templates/nextjs/designs/passepartout/ui/product/discount-badge.tsx +22 -0
  123. package/templates/nextjs/designs/passepartout/ui/product/frequently-bought-together.tsx +205 -0
  124. package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +111 -0
  125. package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +265 -0
  126. package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +667 -0
  127. package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +35 -0
  128. package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +427 -0
  129. package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +113 -0
  130. package/templates/nextjs/designs/passepartout/ui/product/review-form.tsx +294 -0
  131. package/templates/nextjs/designs/passepartout/ui/product/reviews-section.tsx.ejs +96 -0
  132. package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +52 -0
  133. package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +307 -0
  134. package/templates/nextjs/designs/passepartout/ui/shared/fly-to-cart.ts +85 -0
  135. package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +33 -0
  136. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +87 -0
  137. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +121 -0
  138. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +25 -0
  139. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +97 -0
  140. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +103 -0
  141. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
  142. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +117 -0
  143. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +52 -0
  144. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +79 -0
  145. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
  146. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
  147. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
  148. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
  149. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
  150. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
  151. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
  152. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +74 -0
  153. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +34 -0
  154. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
  155. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
  156. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +443 -0
  157. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +17 -0
  158. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +166 -0
  159. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +163 -0
  160. package/templates/nextjs/ui-canvas/product/price-display.tsx +50 -0
  161. package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
  162. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
  163. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
  164. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
  165. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +95 -0
  166. package/templates/nextjs/ui-canvas/product/review-form.tsx +269 -0
  167. package/templates/nextjs/ui-canvas/product/reviews-section.tsx +78 -0
  168. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +43 -0
  169. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +210 -0
  170. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +29 -0
  171. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +25 -0
  172. package/templates/nextjs/base/src/app/home-client.tsx +0 -98
  173. /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
  174. /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
  175. /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
  176. /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
  177. /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
  178. /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
  179. /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
  180. /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
  181. /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
  182. /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
  183. /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
  184. /package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +0 -0
  185. /package/templates/nextjs/base/src/{components → ui}/layout/language-switcher.tsx.ejs +0 -0
  186. /package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +0 -0
@@ -2,11 +2,16 @@
2
2
 
3
3
  import { useState } from 'react';
4
4
  import type { ProductCustomizationField } from 'brainerce';
5
- import { useTranslations } from '@/lib/translations';
6
- import { cn } from '@/lib/utils';
7
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { cn } from '@/core/lib/utils';
7
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
8
+ import type { CustomizationValues } from '@/core/lib/product-options';
8
9
 
9
- export type CustomizationValues = Record<string, unknown>;
10
+ // The pure validation logic lives in core (zero markup) so that
11
+ // `core/hooks/use-product-page` never imports from `ui/`. Re-exported here
12
+ // for backward compatibility with existing imports.
13
+ export { validateCustomization } from '@/core/lib/product-options';
14
+ export type { CustomizationValues } from '@/core/lib/product-options';
10
15
 
11
16
  interface CustomizationFieldsProps {
12
17
  fields: ProductCustomizationField[];
@@ -192,8 +197,8 @@ function CustomizationFieldRow({ field, value, error, onChange }: RowProps) {
192
197
  >
193
198
  <option value="">{t('selectPlaceholder')}</option>
194
199
  {options.map((opt) => (
195
- <option key={opt} value={opt}>
196
- {opt}
200
+ <option key={opt.value} value={opt.value}>
201
+ {opt.label || opt.value}
197
202
  </option>
198
203
  ))}
199
204
  </select>
@@ -214,14 +219,14 @@ function CustomizationFieldRow({ field, value, error, onChange }: RowProps) {
214
219
  {labelText}
215
220
  <div className="space-y-1.5">
216
221
  {options.map((opt) => (
217
- <label key={opt} className="me-4 inline-flex items-center gap-2">
222
+ <label key={opt.value} className="me-4 inline-flex items-center gap-2">
218
223
  <input
219
224
  type="checkbox"
220
- checked={current.includes(opt)}
221
- onChange={() => toggle(opt)}
225
+ checked={current.includes(opt.value)}
226
+ onChange={() => toggle(opt.value)}
222
227
  className="h-4 w-4"
223
228
  />
224
- <span className="text-foreground text-sm">{opt}</span>
229
+ <span className="text-foreground text-sm">{opt.label || opt.value}</span>
225
230
  </label>
226
231
  ))}
227
232
  </div>
@@ -316,7 +321,7 @@ function SingleImageField({
316
321
  setUploading(true);
317
322
  setUploadError(null);
318
323
  try {
319
- const { getClient } = await import('@/lib/brainerce');
324
+ const { getClient } = await import('@/core/lib/brainerce');
320
325
  const client = getClient();
321
326
  const result = await client.uploadCustomizationFile(file);
322
327
  onChange(result.url);
@@ -374,7 +379,7 @@ function GalleryField({
374
379
  setUploading(true);
375
380
  setUploadError(null);
376
381
  try {
377
- const { getClient } = await import('@/lib/brainerce');
382
+ const { getClient } = await import('@/core/lib/brainerce');
378
383
  const client = getClient();
379
384
  const newUrls: string[] = [];
380
385
  for (const file of Array.from(fileList)) {
@@ -437,42 +442,4 @@ function GalleryField({
437
442
  {errorHint}
438
443
  </div>
439
444
  );
440
- }
441
-
442
- export function validateCustomization(
443
- fields: ProductCustomizationField[],
444
- values: CustomizationValues
445
- ): Record<string, string> {
446
- const errors: Record<string, string> = {};
447
- for (const field of fields) {
448
- const value = values[field.key];
449
- const empty =
450
- value === undefined ||
451
- value === null ||
452
- value === '' ||
453
- (Array.isArray(value) && value.length === 0);
454
-
455
- if (field.required && empty) {
456
- errors[field.key] = 'required';
457
- continue;
458
- }
459
- if (empty) continue;
460
-
461
- if (field.type === 'SELECT') {
462
- const allowed = field.enumValues ?? [];
463
- if (typeof value !== 'string' || !allowed.includes(value)) {
464
- errors[field.key] = 'invalidOption';
465
- }
466
- }
467
- if (field.type === 'MULTI_SELECT') {
468
- const allowed = field.enumValues ?? [];
469
- if (
470
- !Array.isArray(value) ||
471
- !(value as unknown[]).every((v) => typeof v === 'string' && allowed.includes(v as string))
472
- ) {
473
- errors[field.key] = 'invalidOption';
474
- }
475
- }
476
- }
477
- return errors;
478
- }
445
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ProductDiscount } from 'brainerce';
2
- import { cn } from '@/lib/utils';
2
+ import { cn } from '@/core/lib/utils';
3
3
 
4
4
  interface DiscountBadgeProps {
5
5
  discount?: ProductDiscount | null;
@@ -1,13 +1,13 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
- import { CdnImage as Image } from '@/components/shared/cdn-image';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
5
  import type { Product, ProductRecommendation } from 'brainerce';
6
6
  import { formatPrice } from 'brainerce';
7
- import { useCart, useStoreInfo } from '@/providers/store-provider';
8
- import { useCurrency } from '@/lib/use-currency';
9
- import { useTranslations } from '@/lib/translations';
10
- import { cn } from '@/lib/utils';
7
+ import { useCart, useStoreInfo } from '@/core/providers/store-provider';
8
+ import { useCurrency } from '@/core/lib/use-currency';
9
+ import { useTranslations } from '@/core/lib/translations';
10
+ import { cn } from '@/core/lib/utils';
11
11
 
12
12
  interface FrequentlyBoughtTogetherProps {
13
13
  items: ProductRecommendation[];
@@ -132,7 +132,7 @@ export function FrequentlyBoughtTogether({
132
132
  if (adding || selected.size === 0) return;
133
133
  try {
134
134
  setAdding(true);
135
- const { getClient } = await import('@/lib/brainerce');
135
+ const { getClient } = await import('@/core/lib/brainerce');
136
136
  const client = getClient();
137
137
  const selectedItems = crossSells.filter((item) => selected.has(item.id));
138
138
  for (const item of selectedItems) {
@@ -23,7 +23,16 @@
23
23
  * `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
24
24
  */
25
25
 
26
- import type { Modifier, ModifierGroup, ModifierSelection } from 'brainerce';
26
+ import type { Modifier, ModifierGroup } from 'brainerce';
27
+
28
+ // The pure selection logic (defaults, validation, wire-format adapter) lives
29
+ // in core (zero markup) so that `core/hooks/use-product-page` never imports
30
+ // from `ui/`. Re-exported here for backward compatibility with existing imports.
31
+ export {
32
+ buildInitialSelections,
33
+ toModifierSelections,
34
+ validateSelections,
35
+ } from '@/core/lib/product-options';
27
36
 
28
37
  interface ModifierGroupSelectorProps {
29
38
  /**
@@ -164,75 +173,3 @@ function ruleSummary(group: ModifierGroup): string {
164
173
  }
165
174
  return parts.length ? `(${parts.join(', ')})` : '';
166
175
  }
167
-
168
- /**
169
- * Lightweight client-side validation that mirrors the server's checks. Use
170
- * this to gate the add-to-cart button — but always treat the server's 400
171
- * (`MODIFIER_VALIDATION_FAILED`) as the authoritative answer.
172
- */
173
- export function validateSelections(
174
- groups: ModifierGroup[],
175
- selections: Record<string, string[]>
176
- ): string | null {
177
- for (const group of groups) {
178
- if (group.max === 0) continue; // disabled-for-variant — skip
179
- const picks = selections[group.id] ?? [];
180
- if (group.required && picks.length === 0) {
181
- return `${group.name} is required`;
182
- }
183
- if (picks.length < group.min) {
184
- return `Pick at least ${group.min} from ${group.name}`;
185
- }
186
- if (group.max != null && picks.length > group.max) {
187
- return `Pick at most ${group.max} from ${group.name}`;
188
- }
189
- if (group.selectionType === 'SINGLE' && picks.length > 1) {
190
- return `Only one option allowed in ${group.name}`;
191
- }
192
- }
193
- return null;
194
- }
195
-
196
- /**
197
- * Adapter: convert the local Record<groupId, modifierIds> shape used by this
198
- * component into the wire-format `ModifierSelection[]` array the SDK expects
199
- * on `addToCart` / `updateCartItem`.
200
- */
201
- export function toModifierSelections(
202
- groups: ModifierGroup[],
203
- selections: Record<string, string[]>
204
- ): ModifierSelection[] {
205
- const out: ModifierSelection[] = [];
206
- for (const group of groups) {
207
- if (group.max === 0) continue;
208
- const picks = selections[group.id] ?? [];
209
- if (picks.length === 0) continue;
210
- out.push({ modifierGroupId: group.id, modifierIds: picks });
211
- }
212
- return out;
213
- }
214
-
215
- /**
216
- * Build the initial selection map from the group's defaults — call once on
217
- * first render for each group. Honors `defaultModifierIds` first (per-attach
218
- * defaults), falling back to per-modifier `isDefault` flags. Sold-out
219
- * modifiers are filtered out so the user doesn't start with an invalid pick.
220
- */
221
- export function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
222
- const out: Record<string, string[]> = {};
223
- for (const group of groups) {
224
- if (group.max === 0) continue;
225
- const fromDefaults = group.defaultModifierIds ?? [];
226
- const fromIsDefault = group.modifiers
227
- .filter((m) => m.isDefault && m.available)
228
- .map((m) => m.id);
229
- const merged =
230
- fromDefaults.length > 0
231
- ? fromDefaults.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
232
- : fromIsDefault;
233
- // Cap by max for SINGLE groups — defaults can't violate selection rules.
234
- const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
235
- if (capped.length > 0) out[group.id] = capped;
236
- }
237
- return out;
238
- }
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import { formatPrice } from 'brainerce';
4
- import { useCurrency } from '@/lib/use-currency';
5
- import { cn } from '@/lib/utils';
4
+ import { useCurrency } from '@/core/lib/use-currency';
5
+ import { cn } from '@/core/lib/utils';
6
6
 
7
7
  interface PriceDisplayProps {
8
8
  price: string | number;
@@ -1,17 +1,17 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
- import { Link, useRouter } from '@/lib/navigation';
5
- import { CdnImage as Image } from '@/components/shared/cdn-image';
4
+ import { Link, useRouter } from '@/core/lib/navigation';
5
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
6
  import type { Product } from 'brainerce';
7
7
  import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
8
- import { useTranslations } from '@/lib/translations';
9
- import { PriceDisplay } from '@/components/shared/price-display';
10
- import { StockBadge } from '@/components/products/stock-badge';
11
- import { DiscountBadge } from '@/components/products/discount-badge';
12
- import { useCart } from '@/providers/store-provider';
13
- import { useCurrency } from '@/lib/use-currency';
14
- import { cn } from '@/lib/utils';
8
+ import { useTranslations } from '@/core/lib/translations';
9
+ import { PriceDisplay } from '@/ui/product/price-display';
10
+ import { StockBadge } from '@/ui/product/stock-badge';
11
+ import { DiscountBadge } from '@/ui/product/discount-badge';
12
+ import { useCart } from '@/core/providers/store-provider';
13
+ import { useCurrency } from '@/core/lib/use-currency';
14
+ import { cn } from '@/core/lib/utils';
15
15
 
16
16
  interface ProductCardProps {
17
17
  product: Product;
@@ -115,7 +115,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
115
115
 
116
116
  try {
117
117
  setAdding(true);
118
- const { getClient } = await import('@/lib/brainerce');
118
+ const { getClient } = await import('@/core/lib/brainerce');
119
119
  const client = getClient();
120
120
  await client.smartAddToCart({ productId: product.id, quantity: 1 });
121
121
  await refreshCart();
@@ -1,37 +1,19 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useState, useMemo } from 'react';
4
- import { CdnImage as Image } from '@/components/shared/cdn-image';
5
- import type {
6
- Product,
7
- ProductVariant,
8
- ProductImage,
9
- ProductMetafield,
10
- DownloadFile,
11
- ModifierGroup,
12
- } from 'brainerce';
13
- import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
14
- import { useCart } from '@/providers/store-provider';
15
- import { PriceDisplay } from '@/components/shared/price-display';
16
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
17
- import { VariantSelector } from '@/components/products/variant-selector';
18
- import { StockBadge } from '@/components/products/stock-badge';
19
- import { RecommendationSection } from '@/components/products/recommendation-section';
20
- import { FrequentlyBoughtTogether } from '@/components/products/frequently-bought-together';
21
- import {
22
- CustomizationFields,
23
- validateCustomization,
24
- type CustomizationValues,
25
- } from '@/components/products/customization-fields';
26
- import {
27
- ModifierGroupSelector,
28
- buildInitialSelections,
29
- toModifierSelections,
30
- validateSelections,
31
- } from '@/components/products/modifier-group-selector';
32
- import { useTranslations } from '@/lib/translations';
33
- import { cn } from '@/lib/utils';
34
- import { sanitizeProductHtml } from '@/lib/sanitize-html';
3
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
4
+ import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
5
+ import { useProductPage } from '@/core/hooks/use-product-page';
6
+ import { PriceDisplay } from '@/ui/product/price-display';
7
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
8
+ import { VariantSelector } from '@/ui/product/variant-selector';
9
+ import { StockBadge } from '@/ui/product/stock-badge';
10
+ import { RecommendationSection } from '@/ui/product/recommendation-section';
11
+ import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
12
+ import { CustomizationFields } from '@/ui/product/customization-fields';
13
+ import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
14
+ import { useTranslations } from '@/core/lib/translations';
15
+ import { cn } from '@/core/lib/utils';
16
+ import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
35
17
 
36
18
  /** Render a metafield value based on its type */
37
19
  function MetafieldValue({ field }: { field: ProductMetafield }) {
@@ -122,176 +104,35 @@ interface ProductClientSectionProps {
122
104
  }
123
105
 
124
106
  export function ProductClientSection({ product: initialProduct }: ProductClientSectionProps) {
125
- const { refreshCart } = useCart();
126
107
  const t = useTranslations('productDetail');
127
108
 
128
- const product = initialProduct;
129
- const recommendations = product?.recommendations ?? null;
130
-
131
- const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
132
- product.variants && product.variants.length > 0 ? product.variants[0] : null
133
- );
134
- const [selectedImageIndex, setSelectedImageIndex] = useState(0);
135
- const [quantity, setQuantity] = useState(1);
136
- const [addingToCart, setAddingToCart] = useState(false);
137
- const [addedMessage, setAddedMessage] = useState(false);
138
- const customizationFields = product.customizationFields ?? [];
139
- const [customizationValues, setCustomizationValues] = useState<CustomizationValues>(() => {
140
- const initial: CustomizationValues = {};
141
- for (const field of customizationFields) {
142
- if (field.defaultValue != null) initial[field.key] = field.defaultValue;
143
- }
144
- return initial;
145
- });
146
- const [customizationErrors, setCustomizationErrors] = useState<Record<string, string>>({});
147
-
148
- // Modifier groups (PRD §8.4) — only present on restaurant / build-your-own products.
149
- const modifierGroups: ModifierGroup[] = useMemo(
150
- () => (product as Product & { modifierGroups?: ModifierGroup[] }).modifierGroups ?? [],
151
- [product]
152
- );
153
- const [modifierSelections, setModifierSelections] = useState<Record<string, string[]>>(() =>
154
- buildInitialSelections(modifierGroups)
155
- );
156
- const [modifierError, setModifierError] = useState<string | null>(null);
157
-
158
- // Images list - switch main image when variant changes
159
- const images: ProductImage[] = useMemo(() => {
160
- return product?.images || [];
161
- }, [product]);
162
-
163
- // When variant changes, update selected image to variant image if available
164
- useEffect(() => {
165
- if (!selectedVariant?.image || !product) return;
166
-
167
- const variantImgUrl =
168
- typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
169
-
170
- // Find if variant image exists in product images
171
- const idx = images.findIndex((img) => img.url === variantImgUrl);
172
- if (idx >= 0) {
173
- setSelectedImageIndex(idx);
174
- } else {
175
- // Variant image not in product images - select index 0 as fallback
176
- setSelectedImageIndex(-1);
177
- }
178
- }, [selectedVariant, images, product]);
179
-
180
- // Determine which image to show
181
- const mainImageUrl = useMemo(() => {
182
- if (selectedImageIndex === -1 && selectedVariant?.image) {
183
- const img = selectedVariant.image;
184
- return typeof img === 'string' ? img : img.url;
185
- }
186
- return images[selectedImageIndex]?.url || null;
187
- }, [selectedImageIndex, selectedVariant, images]);
188
-
189
- // Price info - use variant price if selected, else product price
190
- const priceInfo = useMemo(() => {
191
- if (selectedVariant?.price) {
192
- const variantBase = parseFloat(selectedVariant.price);
193
- const variantSale = selectedVariant.salePrice ? parseFloat(selectedVariant.salePrice) : null;
194
- const variantEffective =
195
- variantSale != null && variantSale < variantBase ? variantSale : variantBase;
196
-
197
- // Overlay any product-level discount rule onto the variant price using the rule's ratio
198
- if (product.discount) {
199
- const ruleOriginal = parseFloat(product.discount.originalPrice) || 0;
200
- const ruleDiscounted = parseFloat(product.discount.discountedPrice) || 0;
201
- const ratio = ruleOriginal > 0 ? ruleDiscounted / ruleOriginal : 1;
202
- const discounted = variantEffective * ratio;
203
- const amount = Math.max(0, variantEffective - discounted);
204
- return {
205
- price: discounted,
206
- originalPrice: variantEffective,
207
- isOnSale: discounted < variantEffective,
208
- discountAmount: amount,
209
- discountPercent: variantEffective > 0 ? Math.round((amount / variantEffective) * 100) : 0,
210
- };
211
- }
212
-
213
- return {
214
- price: variantEffective,
215
- originalPrice: variantBase,
216
- isOnSale: variantEffective < variantBase,
217
- discountPercent:
218
- variantEffective < variantBase && variantBase > 0
219
- ? Math.round(((variantBase - variantEffective) / variantBase) * 100)
220
- : 0,
221
- };
222
- }
223
- return getProductPriceInfo(product);
224
- }, [product, selectedVariant]);
225
-
226
- // Inventory: use variant inventory if selected, else product inventory
227
- const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
228
- const canPurchase = inventory?.canPurchase !== false;
229
-
230
- // Description
231
- const description = useMemo(() => {
232
- return product ? getDescriptionContent(product) : null;
233
- }, [product]);
234
-
235
- async function handleAddToCart() {
236
- if (!product || addingToCart) return;
237
-
238
- if (customizationFields.length > 0) {
239
- const errs = validateCustomization(customizationFields, customizationValues);
240
- if (Object.keys(errs).length > 0) {
241
- setCustomizationErrors(errs);
242
- return;
243
- }
244
- }
245
- setCustomizationErrors({});
246
-
247
- // Client-side modifier validation mirrors the server's checks. The server
248
- // is authoritative — `MODIFIER_VALIDATION_FAILED` envelope on add-to-cart
249
- // failure is the source of truth — but pre-flighting catches the obvious
250
- // issues without a round-trip.
251
- if (modifierGroups.length > 0) {
252
- const error = validateSelections(modifierGroups, modifierSelections);
253
- if (error) {
254
- setModifierError(error);
255
- return;
256
- }
257
- }
258
- setModifierError(null);
259
-
260
- const selections =
261
- modifierGroups.length > 0
262
- ? toModifierSelections(modifierGroups, modifierSelections)
263
- : undefined;
264
-
265
- try {
266
- setAddingToCart(true);
267
- const { getClient } = await import('@/lib/brainerce');
268
- const client = getClient();
269
- await client.smartAddToCart({
270
- productId: product.id,
271
- variantId: selectedVariant?.id,
272
- quantity,
273
- metadata:
274
- customizationFields.length > 0 && Object.keys(customizationValues).length > 0
275
- ? customizationValues
276
- : undefined,
277
- ...(selections && selections.length > 0 ? { selections } : {}),
278
- });
279
- await refreshCart();
280
- setAddedMessage(true);
281
- setTimeout(() => setAddedMessage(false), 2000);
282
- } catch (err) {
283
- // Surface the structured `MODIFIER_VALIDATION_FAILED` envelope when present.
284
- const e = err as { details?: { code?: string; errors?: Array<{ message: string }> } };
285
- const validationErrors = e?.details?.errors;
286
- if (e?.details?.code === 'MODIFIER_VALIDATION_FAILED' && validationErrors?.length) {
287
- setModifierError(validationErrors.map((v) => v.message).join('; '));
288
- } else {
289
- console.error('Failed to add to cart:', err);
290
- }
291
- } finally {
292
- setAddingToCart(false);
293
- }
294
- }
109
+ const {
110
+ product,
111
+ recommendations,
112
+ images,
113
+ selectedImageIndex,
114
+ setSelectedImageIndex,
115
+ mainImageUrl,
116
+ selectedVariant,
117
+ setSelectedVariant,
118
+ priceInfo,
119
+ inventory,
120
+ canPurchase,
121
+ description,
122
+ quantity,
123
+ setQuantity,
124
+ addingToCart,
125
+ addedMessage,
126
+ handleAddToCart,
127
+ customizationFields,
128
+ customizationValues,
129
+ setCustomizationValues,
130
+ customizationErrors,
131
+ modifierGroups,
132
+ modifierSelections,
133
+ setModifierSelection,
134
+ modifierError,
135
+ } = useProductPage(initialProduct);
295
136
 
296
137
  return (
297
138
  <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
@@ -384,10 +225,10 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
384
225
  <h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
385
226
 
386
227
  {/* Tags */}
387
- {(product as { tags?: Array<{ id: string; name: string }> }).tags &&
388
- (product as { tags: Array<{ id: string; name: string }> }).tags.length > 0 && (
228
+ {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
229
+ (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length > 0 && (
389
230
  <div className="flex flex-wrap gap-1.5">
390
- {(product as { tags: Array<{ id: string; name: string }> }).tags.map((tag) => (
231
+ {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map((tag) => (
391
232
  <span
392
233
  key={tag.id}
393
234
  className="border-border text-muted-foreground rounded-full border px-2.5 py-0.5 text-xs"
@@ -490,9 +331,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
490
331
  key={group.attachmentId ?? group.id}
491
332
  group={group}
492
333
  value={modifierSelections[group.id] ?? []}
493
- onChange={(next) =>
494
- setModifierSelections((prev) => ({ ...prev, [group.id]: next }))
495
- }
334
+ onChange={(next) => setModifierSelection(group.id, next)}
496
335
  disabled={addingToCart}
497
336
  />
498
337
  ))}
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import type { Product } from 'brainerce';
4
- import { useTranslations } from '@/lib/translations';
5
- import { ProductCard } from '@/components/products/product-card';
6
- import { cn } from '@/lib/utils';
4
+ import { useTranslations } from '@/core/lib/translations';
5
+ import { ProductCard } from '@/ui/product/product-card';
6
+ import { cn } from '@/core/lib/utils';
7
7
 
8
8
  interface ProductGridProps {
9
9
  products: Product[];