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,260 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef } 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 { flyToCart } from '@/ui/shared/fly-to-cart';
14
+ import { openCartDrawer } from '@/ui/cart/cart-drawer';
15
+ import { useCurrency } from '@/core/lib/use-currency';
16
+ import { cn } from '@/core/lib/utils';
17
+ import { IconBag, IconCheck, IconDownload } from '@/ui/shared/icons';
18
+
19
+ interface ProductCardProps {
20
+ product: Product;
21
+ className?: string;
22
+ }
23
+
24
+ /**
25
+ * Pick the price + currency to render for a given product (PRD §23 FX overlay).
26
+ *
27
+ * When the storefront passed `regionId` to `getProducts({ regionId })` AND the
28
+ * region currency differs from the store currency, the backend attaches
29
+ * additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
30
+ * to the canonical store-currency `basePrice` / `salePrice`. Either way the
31
+ * cart still charges in the store currency — this only affects display.
32
+ */
33
+ function pickDisplayPrice(
34
+ product: Product,
35
+ fallbackCurrency: string
36
+ ): { price: number | undefined; salePrice: number | null; currency: string } {
37
+ if (product.displayPrice != null && product.displayCurrency) {
38
+ const sale =
39
+ product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
40
+ return {
41
+ // `displayPrice` is the base price converted to the region currency —
42
+ // it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
43
+ price: parseFloat(product.displayPrice),
44
+ salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
45
+ currency: product.displayCurrency,
46
+ };
47
+ }
48
+ // Same-currency fallback. `getProductPriceInfo.price` is the EFFECTIVE
49
+ // charged amount (= salePrice when on sale, base otherwise); `originalPrice`
50
+ // is the base. Map them to PriceDisplay's (price = base, salePrice = sale).
51
+ const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
52
+ return {
53
+ price: originalPrice,
54
+ salePrice: isOnSale ? effective : null,
55
+ currency: fallbackCurrency,
56
+ };
57
+ }
58
+
59
+ function VariantPriceRange({ product }: { product: Product }) {
60
+ const currency = useCurrency();
61
+
62
+ let min: number;
63
+ let max: number;
64
+ if (product.priceMin && product.priceMax) {
65
+ min = parseFloat(product.priceMin);
66
+ max = parseFloat(product.priceMax);
67
+ } else {
68
+ const variants = product.variants ?? [];
69
+ if (variants.length === 0) return null;
70
+ const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
71
+ min = Math.min(...prices);
72
+ max = Math.max(...prices);
73
+ }
74
+
75
+ if (isNaN(min) || isNaN(max)) return null;
76
+
77
+ return (
78
+ <span className="text-base font-semibold text-foreground">
79
+ {min === max
80
+ ? (formatPrice(min, { currency }) as string)
81
+ : `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
82
+ </span>
83
+ );
84
+ }
85
+
86
+ export function ProductCard({ product, className }: ProductCardProps) {
87
+ const t = useTranslations('common');
88
+ const tp = useTranslations('productDetail');
89
+ const tProd = useTranslations('products');
90
+ const router = useRouter();
91
+ const { refreshCart } = useCart();
92
+ const fallbackCurrency = useCurrency();
93
+ // FX overlay (PRD §23): prefer the region-converted display values when the
94
+ // storefront passed regionId to getProducts. Otherwise fall back to the
95
+ // canonical store-currency basePrice / salePrice.
96
+ const display = pickDisplayPrice(product, fallbackCurrency);
97
+ const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
98
+ const mainImage = product.images?.[0];
99
+ const hoverImage = product.images?.[1];
100
+ const imageUrl = mainImage?.url || null;
101
+ const slug = product.slug || product.id;
102
+ const isVariable = product.type === 'VARIABLE';
103
+
104
+ const [adding, setAdding] = useState(false);
105
+ const [added, setAdded] = useState(false);
106
+
107
+ const canPurchase = product.inventory?.canPurchase !== false;
108
+
109
+ const imageWellRef = useRef<HTMLDivElement>(null);
110
+
111
+ async function handleAddToCart(e: React.MouseEvent) {
112
+ e.preventDefault();
113
+ e.stopPropagation();
114
+
115
+ if (isVariable) {
116
+ router.push(`/products/${slug}`);
117
+ return;
118
+ }
119
+
120
+ if (adding || !canPurchase) return;
121
+
122
+ try {
123
+ flyToCart(imageWellRef.current);
124
+ setAdding(true);
125
+ const { getClient } = await import('@/core/lib/brainerce');
126
+ const client = getClient();
127
+ await client.smartAddToCart({ productId: product.id, quantity: 1 });
128
+ await refreshCart();
129
+ openCartDrawer();
130
+ setAdded(true);
131
+ setTimeout(() => setAdded(false), 2000);
132
+ } catch (err) {
133
+ console.error('Failed to add to cart:', err);
134
+ } finally {
135
+ setAdding(false);
136
+ }
137
+ }
138
+
139
+ return (
140
+ <article
141
+ className={cn(
142
+ 'group card card-hover relative flex flex-col overflow-hidden',
143
+ className
144
+ )}
145
+ >
146
+ <figure className="relative">
147
+ <Link href={`/products/${slug}`} className="block" tabIndex={-1} aria-hidden="true">
148
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
149
+ <div ref={imageWellRef} className="relative aspect-square overflow-hidden bg-secondary">
150
+ {imageUrl ? (
151
+ <>
152
+ <Image
153
+ src={imageUrl}
154
+ alt={mainImage?.alt || product.name}
155
+ fill
156
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
157
+ className={cn(
158
+ 'img-zoom object-cover',
159
+ hoverImage && 'transition-opacity duration-500 group-hover:opacity-0'
160
+ )}
161
+ />
162
+ {hoverImage && (
163
+ <Image
164
+ src={hoverImage.url}
165
+ alt={hoverImage.alt || product.name}
166
+ fill
167
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
168
+ className="object-cover opacity-0 transition-opacity duration-500 group-hover:opacity-100"
169
+ />
170
+ )}
171
+ </>
172
+ ) : (
173
+ <span className="sr-only">{product.name}</span>
174
+ )}
175
+ </div>
176
+ </Link>
177
+ <figcaption className="sr-only">{product.name}</figcaption>
178
+
179
+ {/* Badges overlay */}
180
+ <div className="absolute start-3 top-3 flex flex-wrap gap-1.5">
181
+ {isOnSale && <span className="badge-primary">{t('sale')}</span>}
182
+ <DiscountBadge discount={product.discount} />
183
+ {product.isDownloadable && (
184
+ <span className="badge-soft">
185
+ <IconDownload size={16} />
186
+ {tp('digitalProduct')}
187
+ </span>
188
+ )}
189
+ </div>
190
+ </figure>
191
+
192
+ <div className="flex flex-1 flex-col gap-1.5 p-4">
193
+ {/* Categories */}
194
+ {product.categories && product.categories.length > 0 && (
195
+ <ul className="flex flex-wrap gap-1 text-xs text-muted-foreground">
196
+ {product.categories.slice(0, 2).map((cat, idx) => (
197
+ <li key={cat.id}>
198
+ {idx > 0 && <span aria-hidden="true">· </span>}
199
+ {cat.name}
200
+ </li>
201
+ ))}
202
+ </ul>
203
+ )}
204
+
205
+ {/* Name — the card-covering link */}
206
+ <h3 className="font-sans text-[15px] font-semibold leading-snug text-foreground">
207
+ <Link
208
+ href={`/products/${slug}`}
209
+ className="after:absolute after:inset-0 after:content-[''] hover:text-primary"
210
+ >
211
+ {product.name}
212
+ </Link>
213
+ </h3>
214
+
215
+ {/* Price */}
216
+ <div>
217
+ {isVariable ? (
218
+ <VariantPriceRange product={product} />
219
+ ) : (
220
+ <PriceDisplay
221
+ price={display.price ?? originalPrice}
222
+ salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
223
+ currency={display.currency}
224
+ size="md"
225
+ />
226
+ )}
227
+ </div>
228
+
229
+ <StockBadge inventory={product.inventory} />
230
+
231
+ {/* Add to cart (variable products route to the product page for option selection) */}
232
+ {(isVariable || canPurchase) && (
233
+ <button
234
+ type="button"
235
+ onClick={handleAddToCart}
236
+ disabled={adding}
237
+ className={cn(
238
+ 'btn-outline btn-sm relative z-10 mt-2 w-full',
239
+ added && 'border-primary bg-primary text-primary-foreground hover:bg-primary'
240
+ )}
241
+ >
242
+ {added ? (
243
+ <>
244
+ <IconCheck size={16} />
245
+ {tp('addedToCart')}
246
+ </>
247
+ ) : isVariable ? (
248
+ tProd('selectOptions')
249
+ ) : (
250
+ <>
251
+ <IconBag size={16} />
252
+ {adding ? tp('addingToCart') : tp('addToCart')}
253
+ </>
254
+ )}
255
+ </button>
256
+ )}
257
+ </div>
258
+ </article>
259
+ );
260
+ }