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,168 @@
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
+
12
+ interface FrequentlyBoughtTogetherProps {
13
+ items: ProductRecommendation[];
14
+ currentProduct: Product;
15
+ className?: string;
16
+ }
17
+
18
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
19
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
20
+ const base = parseFloat(item.basePrice);
21
+ return sale != null && sale < base ? sale : base;
22
+ }
23
+
24
+ function ProductThumb({
25
+ name,
26
+ imageUrl,
27
+ price,
28
+ currency,
29
+ checked,
30
+ onToggle,
31
+ disabled,
32
+ }: {
33
+ name: string;
34
+ imageUrl: string | null;
35
+ price: number;
36
+ currency: string;
37
+ checked: boolean;
38
+ onToggle?: () => void;
39
+ disabled?: boolean;
40
+ }) {
41
+ return (
42
+ <label className="inline-flex items-center gap-2">
43
+ {onToggle && <input type="checkbox" checked={checked} onChange={onToggle} disabled={disabled} />}
44
+ {/* `relative` + fixed box are layout-critical for next/image fill */}
45
+ <span className="relative block h-20 w-20">
46
+ {imageUrl ? <Image src={imageUrl} alt={name} fill sizes="80px" /> : null}
47
+ </span>
48
+ <span>{name}</span>
49
+ <span>{formatPrice(price, { currency }) as string}</span>
50
+ </label>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * DESIGN ME — "frequently bought together" bundle on the product page:
56
+ * current product + selectable cross-sells + combined total + add-all button;
57
+ * items come from useProductPage().recommendations.crossSells, the feature
58
+ * gate from useStoreInfo().upsell.
59
+ *
60
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
61
+ */
62
+ export function FrequentlyBoughtTogether({
63
+ items,
64
+ currentProduct,
65
+ className,
66
+ }: FrequentlyBoughtTogetherProps) {
67
+ // Hooks must be called unconditionally and in the same order on every
68
+ // render — keep all of them above any early `return null` branch.
69
+ const { storeInfo } = useStoreInfo();
70
+ const { refreshCart } = useCart();
71
+ const t = useTranslations('productDetail');
72
+ const currency = useCurrency();
73
+
74
+ // Only show up to 3 cross-sells
75
+ const crossSells = items.slice(0, 3);
76
+
77
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
78
+ const [adding, setAdding] = useState(false);
79
+
80
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
81
+ if (crossSells.length === 0) return null;
82
+
83
+ const currentPrice = getEffectivePrice(currentProduct);
84
+ const currentImage = currentProduct.images?.[0];
85
+ const currentImageUrl = currentImage
86
+ ? typeof currentImage === 'string'
87
+ ? currentImage
88
+ : currentImage.url
89
+ : null;
90
+
91
+ const totalPrice = crossSells
92
+ .filter((item) => selected.has(item.id))
93
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
94
+
95
+ const toggleItem = (id: string) => {
96
+ setSelected((prev) => {
97
+ const next = new Set(prev);
98
+ if (next.has(id)) {
99
+ next.delete(id);
100
+ } else {
101
+ next.add(id);
102
+ }
103
+ return next;
104
+ });
105
+ };
106
+
107
+ async function handleAddAll() {
108
+ if (adding || selected.size === 0) return;
109
+ try {
110
+ setAdding(true);
111
+ const { getClient } = await import('@/core/lib/brainerce');
112
+ const client = getClient();
113
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
114
+ for (const item of selectedItems) {
115
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
116
+ }
117
+ await refreshCart();
118
+ } catch (err) {
119
+ console.error('Failed to add items to cart:', err);
120
+ } finally {
121
+ setAdding(false);
122
+ }
123
+ }
124
+
125
+ return (
126
+ <section className={cn(className)}>
127
+ <h2>{t('frequentlyBoughtTogether')}</h2>
128
+
129
+ <div className="flex flex-wrap items-center gap-3">
130
+ {/* Current product (always included, no checkbox) */}
131
+ <ProductThumb
132
+ name={currentProduct.name}
133
+ imageUrl={currentImageUrl}
134
+ price={currentPrice}
135
+ currency={currency}
136
+ checked={true}
137
+ disabled
138
+ />
139
+
140
+ {crossSells.map((item) => {
141
+ const img = item.images?.[0];
142
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
143
+ return (
144
+ <div key={item.id} className="flex items-center gap-3">
145
+ <span aria-hidden="true">+</span>
146
+ <ProductThumb
147
+ name={item.name}
148
+ imageUrl={imgUrl}
149
+ price={getEffectivePrice(item)}
150
+ currency={currency}
151
+ checked={selected.has(item.id)}
152
+ onToggle={() => toggleItem(item.id)}
153
+ />
154
+ </div>
155
+ );
156
+ })}
157
+ </div>
158
+
159
+ {/* Total + Add button */}
160
+ <div className="flex flex-wrap items-center gap-4">
161
+ <span>{t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}</span>
162
+ <button type="button" onClick={handleAddAll} disabled={adding || selected.size === 0}>
163
+ {adding ? t('addingAll') : t('addSelectedToCart')}
164
+ </button>
165
+ </div>
166
+ </section>
167
+ );
168
+ }
@@ -0,0 +1,165 @@
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
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
24
+ */
25
+
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';
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
+ // PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
58
+ if (group.max === 0) return null;
59
+
60
+ const isSingle = group.selectionType === 'SINGLE';
61
+ // Client-side bound — server is authoritative.
62
+ const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
63
+
64
+ const handleToggle = (modifierId: string, checked: boolean) => {
65
+ if (disabled) return;
66
+
67
+ if (isSingle) {
68
+ // Radio behavior: replace the selection.
69
+ onChange(checked ? [modifierId] : []);
70
+ return;
71
+ }
72
+
73
+ if (checked) {
74
+ if (value.length >= effectiveMax) return; // would exceed max — ignore the click
75
+ if (value.includes(modifierId)) return;
76
+ onChange([...value, modifierId]);
77
+ } else {
78
+ onChange(value.filter((id) => id !== modifierId));
79
+ }
80
+ };
81
+
82
+ const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
83
+
84
+ // Counter: how many of the picked items are within the "free" window.
85
+ const usedFreeSlots = Math.min(value.length, group.freeQuantity);
86
+ const showFreeCounter = group.freeQuantity > 0;
87
+
88
+ return (
89
+ <fieldset>
90
+ <legend>
91
+ <span>{group.name}</span>
92
+ {group.required && <span aria-hidden="true"> *</span>}
93
+ <span> {ruleSummary(group)}</span>
94
+ </legend>
95
+
96
+ {group.description && <p>{group.description}</p>}
97
+
98
+ {showFreeCounter && (
99
+ <p aria-live="polite">
100
+ {usedFreeSlots} of {group.freeQuantity} free
101
+ </p>
102
+ )}
103
+
104
+ <ul>
105
+ {sortedModifiers.map((modifier) => {
106
+ const isChecked = value.includes(modifier.id);
107
+ const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
108
+ const isDisabled = disabled || !modifier.available || isAtCap;
109
+
110
+ return (
111
+ <li key={modifier.id}>
112
+ <label className="inline-flex items-center gap-2">
113
+ <input
114
+ type={isSingle ? 'radio' : 'checkbox'}
115
+ name={`modifier-group-${group.id}`}
116
+ value={modifier.id}
117
+ checked={isChecked}
118
+ disabled={isDisabled}
119
+ onChange={(e) => handleToggle(modifier.id, e.target.checked)}
120
+ />
121
+ <span>{modifier.name}</span>
122
+ <ModifierPriceLabel modifier={modifier} />
123
+ {!modifier.available && <span aria-label="Sold out">Sold out</span>}
124
+ </label>
125
+ {modifier.description && <p>{modifier.description}</p>}
126
+ </li>
127
+ );
128
+ })}
129
+ </ul>
130
+ </fieldset>
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Renders the modifier's price contribution next to its name.
136
+ *
137
+ * Negative deltas display with a leading minus sign (downsell modifiers per
138
+ * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
139
+ * affordance for a free option.
140
+ */
141
+ function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
142
+ const delta = parseFloat(modifier.priceDelta);
143
+ if (Number.isNaN(delta) || delta === 0) return null;
144
+ const sign = delta > 0 ? '+' : '';
145
+ return (
146
+ <span>
147
+ {sign}
148
+ {modifier.priceDelta}
149
+ </span>
150
+ );
151
+ }
152
+
153
+ function ruleSummary(group: ModifierGroup): string {
154
+ const parts: string[] = [];
155
+ if (group.selectionType === 'SINGLE') {
156
+ parts.push('Pick one');
157
+ } else if (group.max != null) {
158
+ if (group.min === 0) parts.push(`Up to ${group.max}`);
159
+ else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
160
+ else parts.push(`${group.min}–${group.max}`);
161
+ } else if (group.min > 0) {
162
+ parts.push(`At least ${group.min}`);
163
+ }
164
+ return parts.length ? `(${parts.join(', ')})` : '';
165
+ }
@@ -0,0 +1,52 @@
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
+ /**
16
+ * DESIGN ME — price renderer used by cards, product page and recommendations;
17
+ * computes sale state + discount % from props (currency via useCurrency()).
18
+ * Never hardcode currency symbols — formatPrice handles them.
19
+ *
20
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
21
+ */
22
+ export function PriceDisplay({
23
+ price,
24
+ salePrice,
25
+ currency,
26
+ className,
27
+ size = 'md',
28
+ }: PriceDisplayProps) {
29
+ const currencyCode = useCurrency(currency);
30
+
31
+ const basePrice = typeof price === 'string' ? parseFloat(price) : price;
32
+ const sale =
33
+ salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
34
+ const isOnSale = sale !== null && sale < basePrice;
35
+
36
+ const discountPercent =
37
+ isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
38
+
39
+ return (
40
+ <span data-size={size} className={cn('inline-flex items-center gap-2', className)}>
41
+ {isOnSale ? (
42
+ <>
43
+ <span>{formatPrice(sale!, { currency: currencyCode }) as string}</span>
44
+ <s>{formatPrice(basePrice, { currency: currencyCode }) as string}</s>
45
+ {discountPercent > 0 && <span>-{discountPercent}%</span>}
46
+ </>
47
+ ) : (
48
+ <span>{formatPrice(basePrice, { currency: currencyCode }) as string}</span>
49
+ )}
50
+ </span>
51
+ );
52
+ }
@@ -0,0 +1,197 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Link, useRouter } from '@/core/lib/navigation';
5
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
+ import type { Product } from 'brainerce';
7
+ import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
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
+
16
+ interface ProductCardProps {
17
+ product: Product;
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * Pick the price + currency to render for a given product (PRD §23 FX overlay).
23
+ *
24
+ * When the storefront passed `regionId` to `getProducts({ regionId })` AND the
25
+ * region currency differs from the store currency, the backend attaches
26
+ * additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
27
+ * to the canonical store-currency `basePrice` / `salePrice`. Either way the
28
+ * cart still charges in the store currency — this only affects display.
29
+ */
30
+ function pickDisplayPrice(
31
+ product: Product,
32
+ fallbackCurrency: string
33
+ ): { price: number | undefined; salePrice: number | null; currency: string } {
34
+ if (product.displayPrice != null && product.displayCurrency) {
35
+ const sale =
36
+ product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
37
+ return {
38
+ // `displayPrice` is the base price converted to the region currency —
39
+ // it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
40
+ price: parseFloat(product.displayPrice),
41
+ salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
42
+ currency: product.displayCurrency,
43
+ };
44
+ }
45
+ // Same-currency fallback. `getProductPriceInfo.price` is the EFFECTIVE
46
+ // charged amount (= salePrice when on sale, base otherwise); `originalPrice`
47
+ // is the base. Map them to PriceDisplay's (price = base, salePrice = sale).
48
+ const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
49
+ return {
50
+ price: originalPrice,
51
+ salePrice: isOnSale ? effective : null,
52
+ currency: fallbackCurrency,
53
+ };
54
+ }
55
+
56
+ function VariantPriceRange({ product }: { product: Product }) {
57
+ const currency = useCurrency();
58
+
59
+ let min: number;
60
+ let max: number;
61
+ if (product.priceMin && product.priceMax) {
62
+ min = parseFloat(product.priceMin);
63
+ max = parseFloat(product.priceMax);
64
+ } else {
65
+ const variants = product.variants ?? [];
66
+ if (variants.length === 0) return null;
67
+ const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
68
+ min = Math.min(...prices);
69
+ max = Math.max(...prices);
70
+ }
71
+
72
+ if (isNaN(min) || isNaN(max)) return null;
73
+
74
+ return (
75
+ <span>
76
+ {min === max
77
+ ? (formatPrice(min, { currency }) as string)
78
+ : `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
79
+ </span>
80
+ );
81
+ }
82
+
83
+ export function ProductCard({ product, className }: ProductCardProps) {
84
+ const t = useTranslations('common');
85
+ const tp = useTranslations('productDetail');
86
+ const tProd = useTranslations('products');
87
+ const router = useRouter();
88
+ const { refreshCart } = useCart();
89
+ const fallbackCurrency = useCurrency();
90
+ // FX overlay (PRD §23): prefer the region-converted display values when the
91
+ // storefront passed regionId to getProducts. Otherwise fall back to the
92
+ // canonical store-currency basePrice / salePrice.
93
+ const display = pickDisplayPrice(product, fallbackCurrency);
94
+ const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
95
+ const mainImage = product.images?.[0];
96
+ const imageUrl = mainImage?.url || null;
97
+ const slug = product.slug || product.id;
98
+ const isVariable = product.type === 'VARIABLE';
99
+
100
+ const [adding, setAdding] = useState(false);
101
+ const [added, setAdded] = useState(false);
102
+
103
+ const canPurchase = product.inventory?.canPurchase !== false;
104
+
105
+ async function handleAddToCart(e: React.MouseEvent) {
106
+ e.preventDefault();
107
+ e.stopPropagation();
108
+
109
+ if (isVariable) {
110
+ router.push(`/products/${slug}`);
111
+ return;
112
+ }
113
+
114
+ if (adding || !canPurchase) return;
115
+
116
+ try {
117
+ setAdding(true);
118
+ const { getClient } = await import('@/core/lib/brainerce');
119
+ const client = getClient();
120
+ await client.smartAddToCart({ productId: product.id, quantity: 1 });
121
+ await refreshCart();
122
+ setAdded(true);
123
+ setTimeout(() => setAdded(false), 2000);
124
+ } catch (err) {
125
+ console.error('Failed to add to cart:', err);
126
+ } finally {
127
+ setAdding(false);
128
+ }
129
+ }
130
+
131
+ return (
132
+ <article className={cn(className)}>
133
+ {/* DESIGN ME — product card (grid/listing unit): image, badges, name, price, stock, add-to-cart; product data arrives as a prop from useHomeData()/useProductListing(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
134
+ <figure>
135
+ <Link href={`/products/${slug}`}>
136
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
137
+ <div className="relative aspect-square">
138
+ {imageUrl ? (
139
+ <Image
140
+ src={imageUrl}
141
+ alt={mainImage?.alt || product.name}
142
+ fill
143
+ sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
144
+ />
145
+ ) : (
146
+ <span className="sr-only">{product.name}</span>
147
+ )}
148
+ </div>
149
+ </Link>
150
+ <figcaption className="sr-only">{product.name}</figcaption>
151
+ </figure>
152
+
153
+ {/* Badges */}
154
+ <div className="flex flex-wrap gap-1">
155
+ {isOnSale && <span>{t('sale')}</span>}
156
+ <DiscountBadge discount={product.discount} />
157
+ {product.isDownloadable && <span>{tp('digitalProduct')}</span>}
158
+ </div>
159
+
160
+ {/* Categories */}
161
+ {product.categories && product.categories.length > 0 && (
162
+ <ul className="flex flex-wrap gap-1">
163
+ {product.categories.slice(0, 2).map((cat) => (
164
+ <li key={cat.id}>{cat.name}</li>
165
+ ))}
166
+ </ul>
167
+ )}
168
+
169
+ {/* Name — clickable */}
170
+ <h3>
171
+ <Link href={`/products/${slug}`}>{product.name}</Link>
172
+ </h3>
173
+
174
+ {/* Price */}
175
+ {isVariable ? (
176
+ <VariantPriceRange product={product} />
177
+ ) : (
178
+ <PriceDisplay
179
+ price={display.price ?? originalPrice}
180
+ salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
181
+ currency={display.currency}
182
+ size="sm"
183
+ />
184
+ )}
185
+
186
+ {/* Stock */}
187
+ <StockBadge inventory={product.inventory} />
188
+
189
+ {/* Add to cart (variable products route to the product page for option selection) */}
190
+ {(isVariable || canPurchase) && (
191
+ <button type="button" onClick={handleAddToCart} disabled={adding}>
192
+ {added ? tp('addedToCart') : isVariable ? tProd('selectOptions') : tp('addToCart')}
193
+ </button>
194
+ )}
195
+ </article>
196
+ );
197
+ }