create-brainerce-store 1.50.5 → 1.52.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 (230) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +146 -100
  3. package/messages/en.json +36 -1
  4. package/messages/he.json +36 -1
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.claude/commands/design.md +60 -0
  7. package/templates/nextjs/base/.env.local.ejs +5 -0
  8. package/templates/nextjs/base/.eslintrc.json +57 -3
  9. package/templates/nextjs/base/AGENTS.md +41 -0
  10. package/templates/nextjs/base/AI-GUIDE.md +143 -0
  11. package/templates/nextjs/base/CLAUDE.md +52 -0
  12. package/templates/nextjs/base/TRANSLATIONS.md +1 -1
  13. package/templates/nextjs/base/next.config.ts +2 -2
  14. package/templates/nextjs/base/package.json.ejs +52 -39
  15. package/templates/nextjs/base/src/app/account/page.tsx +5 -5
  16. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
  17. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
  18. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
  19. package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
  20. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
  21. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
  22. package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
  23. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
  24. package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
  25. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
  26. package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
  27. package/templates/nextjs/base/src/app/globals.css +36 -30
  28. package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
  29. package/templates/nextjs/base/src/app/login/page.tsx +4 -4
  30. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
  31. package/templates/nextjs/base/src/app/page.tsx +4 -4
  32. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
  33. package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
  34. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
  35. package/templates/nextjs/base/src/app/products/page.tsx +4 -623
  36. package/templates/nextjs/base/src/app/register/page.tsx +4 -4
  37. package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
  38. package/templates/nextjs/base/src/app/sitemap.ts +1 -1
  39. package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
  40. package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
  41. package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
  42. package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
  43. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
  44. package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
  45. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
  46. package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
  47. package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
  48. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
  49. package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
  50. package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
  51. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
  52. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
  53. package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
  54. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
  55. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +20 -16
  56. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
  57. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
  58. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
  59. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
  60. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
  61. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
  62. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
  63. package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
  64. package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
  65. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
  66. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
  67. package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
  68. package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
  69. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
  70. package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
  71. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
  72. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
  73. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
  74. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
  75. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
  76. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
  77. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
  78. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
  79. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
  80. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
  81. package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
  82. package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
  83. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
  84. package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
  85. package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
  86. package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
  87. package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
  88. package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
  89. package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +22 -16
  90. package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +8 -14
  91. package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +28 -39
  92. package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +7 -5
  93. package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +13 -28
  94. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -0
  95. package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +14 -25
  96. package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +6 -7
  97. package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +95 -103
  98. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
  99. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
  100. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -0
  101. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
  102. package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +116 -125
  103. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -0
  104. package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +5 -20
  105. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -0
  106. package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
  107. package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +144 -164
  108. package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +152 -182
  109. package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +457 -478
  110. package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +18 -22
  111. package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +21 -33
  112. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -0
  113. package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +5 -4
  114. package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +238 -271
  115. package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +455 -631
  116. package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
  117. package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -0
  118. package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +102 -107
  119. package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +46 -45
  120. package/templates/nextjs/base/src/{components/reviews/reviews-section.tsx → ui/product/reviews-section.tsx.ejs} +38 -15
  121. package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +61 -63
  122. package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
  123. package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
  124. package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
  125. package/templates/nextjs/base/tailwind.config.ts +69 -30
  126. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +251 -0
  127. package/templates/nextjs/designs/atelier/globals.css +396 -0
  128. package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
  129. package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
  130. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -0
  131. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
  132. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
  133. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
  134. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
  135. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -0
  136. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
  137. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
  138. package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -0
  139. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -0
  140. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
  141. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
  142. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
  143. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
  144. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
  145. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
  146. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
  147. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
  148. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
  149. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
  150. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
  151. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
  152. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
  153. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
  154. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
  155. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
  156. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
  157. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
  158. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
  159. package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -0
  160. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -0
  161. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
  162. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
  163. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
  164. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
  165. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
  166. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
  167. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
  168. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -0
  169. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
  170. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
  171. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
  172. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
  173. package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -0
  174. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
  175. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
  176. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
  177. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
  178. package/templates/nextjs/themes/luxury/globals.css +405 -399
  179. package/templates/nextjs/themes/minimal/globals.css +36 -30
  180. package/templates/nextjs/themes/playful/globals.css +406 -400
  181. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -0
  182. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -0
  183. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -0
  184. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -0
  185. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -0
  186. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
  187. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -0
  188. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -0
  189. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -0
  190. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
  191. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
  192. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
  193. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
  194. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
  195. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
  196. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
  197. package/templates/nextjs/{base/src/components → ui-canvas}/layout/language-switcher.tsx.ejs +76 -70
  198. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -0
  199. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
  200. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
  201. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -0
  202. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -0
  203. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -0
  204. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -0
  205. package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -0
  206. package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
  207. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
  208. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
  209. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
  210. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -0
  211. package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -0
  212. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -0
  213. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -0
  214. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -0
  215. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -0
  216. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -0
  217. package/templates/nextjs/base/src/app/home-client.tsx +0 -98
  218. package/templates/nextjs/base/src/components/content/faq-section.tsx.ejs +0 -103
  219. package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +0 -238
  220. /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
  221. /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
  222. /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
  223. /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
  224. /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
  225. /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
  226. /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
  227. /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
  228. /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
  229. /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
  230. /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
@@ -0,0 +1,196 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { Product, ProductRecommendation } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
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
+ import { IconPlus, IconBag } from '@/ui/shared/icons';
12
+
13
+ interface FrequentlyBoughtTogetherProps {
14
+ items: ProductRecommendation[];
15
+ currentProduct: Product;
16
+ className?: string;
17
+ }
18
+
19
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
20
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
21
+ const base = parseFloat(item.basePrice);
22
+ return sale != null && sale < base ? sale : base;
23
+ }
24
+
25
+ function ProductThumb({
26
+ name,
27
+ imageUrl,
28
+ price,
29
+ currency,
30
+ checked,
31
+ onToggle,
32
+ disabled,
33
+ }: {
34
+ name: string;
35
+ imageUrl: string | null;
36
+ price: number;
37
+ currency: string;
38
+ checked: boolean;
39
+ onToggle?: () => void;
40
+ disabled?: boolean;
41
+ }) {
42
+ return (
43
+ <label
44
+ className={cn(
45
+ 'flex w-40 cursor-pointer flex-col gap-2 rounded-xl border bg-background p-3 transition-colors',
46
+ checked ? 'border-primary' : 'border-border',
47
+ disabled && 'cursor-default'
48
+ )}
49
+ >
50
+ {/* `relative` + fixed box are layout-critical for next/image fill */}
51
+ <span className="relative block aspect-square overflow-hidden rounded-lg bg-secondary">
52
+ {imageUrl ? <Image src={imageUrl} alt={name} fill sizes="160px" className="object-cover" /> : null}
53
+ {onToggle && (
54
+ <input
55
+ type="checkbox"
56
+ checked={checked}
57
+ onChange={onToggle}
58
+ disabled={disabled}
59
+ className="absolute end-2 top-2 h-[18px] w-[18px] accent-primary"
60
+ aria-label={name}
61
+ />
62
+ )}
63
+ </span>
64
+ <span className="line-clamp-2 text-xs font-semibold leading-snug text-foreground">
65
+ {name}
66
+ </span>
67
+ <span className="text-sm font-semibold text-foreground">
68
+ {formatPrice(price, { currency }) as string}
69
+ </span>
70
+ </label>
71
+ );
72
+ }
73
+
74
+ /**
75
+ * "Frequently bought together" bundle on the product page: current product +
76
+ * selectable cross-sells + combined total + add-all button; items come from
77
+ * useProductPage().recommendations.crossSells, the feature gate from
78
+ * useStoreInfo().upsell.
79
+ */
80
+ export function FrequentlyBoughtTogether({
81
+ items,
82
+ currentProduct,
83
+ className,
84
+ }: FrequentlyBoughtTogetherProps) {
85
+ // Hooks must be called unconditionally and in the same order on every
86
+ // render — keep all of them above any early `return null` branch.
87
+ const { storeInfo } = useStoreInfo();
88
+ const { refreshCart } = useCart();
89
+ const t = useTranslations('productDetail');
90
+ const currency = useCurrency();
91
+
92
+ // Only show up to 3 cross-sells
93
+ const crossSells = items.slice(0, 3);
94
+
95
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
96
+ const [adding, setAdding] = useState(false);
97
+
98
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
99
+ if (crossSells.length === 0) return null;
100
+
101
+ const currentPrice = getEffectivePrice(currentProduct);
102
+ const currentImage = currentProduct.images?.[0];
103
+ const currentImageUrl = currentImage
104
+ ? typeof currentImage === 'string'
105
+ ? currentImage
106
+ : currentImage.url
107
+ : null;
108
+
109
+ const totalPrice = crossSells
110
+ .filter((item) => selected.has(item.id))
111
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
112
+
113
+ const toggleItem = (id: string) => {
114
+ setSelected((prev) => {
115
+ const next = new Set(prev);
116
+ if (next.has(id)) {
117
+ next.delete(id);
118
+ } else {
119
+ next.add(id);
120
+ }
121
+ return next;
122
+ });
123
+ };
124
+
125
+ async function handleAddAll() {
126
+ if (adding || selected.size === 0) return;
127
+ try {
128
+ setAdding(true);
129
+ const { getClient } = await import('@/core/lib/brainerce');
130
+ const client = getClient();
131
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
132
+ for (const item of selectedItems) {
133
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
134
+ }
135
+ await refreshCart();
136
+ } catch (err) {
137
+ console.error('Failed to add items to cart:', err);
138
+ } finally {
139
+ setAdding(false);
140
+ }
141
+ }
142
+
143
+ return (
144
+ <section className={cn('rounded-2xl border bg-secondary/50 p-6 sm:p-8', className)}>
145
+ <h2 className="mb-6 text-2xl">{t('frequentlyBoughtTogether')}</h2>
146
+
147
+ <div className="flex flex-wrap items-center gap-3">
148
+ {/* Current product (always included, no checkbox) */}
149
+ <ProductThumb
150
+ name={currentProduct.name}
151
+ imageUrl={currentImageUrl}
152
+ price={currentPrice}
153
+ currency={currency}
154
+ checked={true}
155
+ disabled
156
+ />
157
+
158
+ {crossSells.map((item) => {
159
+ const img = item.images?.[0];
160
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
161
+ return (
162
+ <div key={item.id} className="flex items-center gap-3">
163
+ <span aria-hidden="true" className="text-muted-foreground">
164
+ <IconPlus size={20} />
165
+ </span>
166
+ <ProductThumb
167
+ name={item.name}
168
+ imageUrl={imgUrl}
169
+ price={getEffectivePrice(item)}
170
+ currency={currency}
171
+ checked={selected.has(item.id)}
172
+ onToggle={() => toggleItem(item.id)}
173
+ />
174
+ </div>
175
+ );
176
+ })}
177
+ </div>
178
+
179
+ {/* Total + Add button */}
180
+ <div className="mt-6 flex flex-wrap items-center gap-4 border-t pt-5">
181
+ <span className="text-lg font-semibold text-foreground">
182
+ {t('totalPrice').replace('{price}', formatPrice(totalPrice, { currency }) as string)}
183
+ </span>
184
+ <button
185
+ type="button"
186
+ onClick={handleAddAll}
187
+ disabled={adding || selected.size === 0}
188
+ className="btn-primary btn-md"
189
+ >
190
+ <IconBag size={18} />
191
+ {adding ? t('addingAll') : t('addSelectedToCart')}
192
+ </button>
193
+ </div>
194
+ </section>
195
+ );
196
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }