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
@@ -1,271 +1,238 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { Link, useRouter } from '@/lib/navigation';
5
- import { CdnImage as Image } from '@/components/shared/cdn-image';
6
- import type { Product } from 'brainerce';
7
- import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
8
- import { useTranslations } from '@/lib/translations';
9
- import { PriceDisplay } from '@/components/shared/price-display';
10
- import { StockBadge } from '@/components/products/stock-badge';
11
- import { DiscountBadge } from '@/components/products/discount-badge';
12
- import { useCart } from '@/providers/store-provider';
13
- import { useCurrency } from '@/lib/use-currency';
14
- import { cn } from '@/lib/utils';
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 className="text-foreground text-sm font-medium">
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('@/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
- <div
133
- className={cn(
134
- 'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
135
- className
136
- )}
137
- >
138
- {/* Image — clickable */}
139
- <Link href={`/products/${slug}`} className="block">
140
- <div className="bg-muted relative aspect-square overflow-hidden">
141
- {imageUrl ? (
142
- <Image
143
- src={imageUrl}
144
- alt={mainImage?.alt || product.name}
145
- fill
146
- sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
147
- className="object-cover transition-transform duration-300 group-hover:scale-105"
148
- />
149
- ) : (
150
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
151
- <svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
152
- <path
153
- strokeLinecap="round"
154
- strokeLinejoin="round"
155
- strokeWidth={1.5}
156
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
157
- />
158
- </svg>
159
- </div>
160
- )}
161
-
162
- {/* Badges */}
163
- <div className="absolute start-2 top-2 flex flex-col gap-1">
164
- {isOnSale && (
165
- <span className="bg-destructive text-destructive-foreground rounded px-2 py-1 text-xs font-bold">
166
- {t('sale')}
167
- </span>
168
- )}
169
- <DiscountBadge discount={product.discount} />
170
- {product.isDownloadable && (
171
- <span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
172
- {tp('digitalProduct')}
173
- </span>
174
- )}
175
- </div>
176
-
177
- {/* Add to cart overlay button */}
178
- {(isVariable || canPurchase) && (
179
- <button
180
- onClick={handleAddToCart}
181
- disabled={adding}
182
- aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
183
- className={cn(
184
- 'absolute bottom-2 end-2 flex h-8 w-8 items-center justify-center rounded-full shadow-md transition-all',
185
- 'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
186
- added
187
- ? 'bg-green-500 text-white'
188
- : 'bg-primary text-primary-foreground hover:opacity-90'
189
- )}
190
- >
191
- {added ? (
192
- <svg
193
- className="h-4 w-4"
194
- fill="none"
195
- viewBox="0 0 24 24"
196
- stroke="currentColor"
197
- strokeWidth={2.5}
198
- >
199
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
200
- </svg>
201
- ) : isVariable ? (
202
- <svg
203
- className="h-4 w-4"
204
- fill="none"
205
- viewBox="0 0 24 24"
206
- stroke="currentColor"
207
- strokeWidth={2}
208
- >
209
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
210
- </svg>
211
- ) : (
212
- <svg
213
- className="h-4 w-4"
214
- fill="none"
215
- viewBox="0 0 24 24"
216
- stroke="currentColor"
217
- strokeWidth={2}
218
- >
219
- <path
220
- strokeLinecap="round"
221
- strokeLinejoin="round"
222
- d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
223
- />
224
- </svg>
225
- )}
226
- </button>
227
- )}
228
- </div>
229
- </Link>
230
-
231
- {/* Content */}
232
- <div className="space-y-2 p-3">
233
- {/* Categories */}
234
- {product.categories && product.categories.length > 0 && (
235
- <div className="flex flex-wrap gap-1">
236
- {product.categories.slice(0, 2).map((cat) => (
237
- <span
238
- key={cat.id}
239
- className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]"
240
- >
241
- {cat.name}
242
- </span>
243
- ))}
244
- </div>
245
- )}
246
-
247
- {/* Name — clickable */}
248
- <Link href={`/products/${slug}`}>
249
- <h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
250
- {product.name}
251
- </h3>
252
- </Link>
253
-
254
- {/* Price */}
255
- {isVariable ? (
256
- <VariantPriceRange product={product} />
257
- ) : (
258
- <PriceDisplay
259
- price={display.price ?? originalPrice}
260
- salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
261
- currency={display.currency}
262
- size="sm"
263
- />
264
- )}
265
-
266
- {/* Stock */}
267
- <StockBadge inventory={product.inventory} />
268
- </div>
269
- </div>
270
- );
271
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Check, Image as ImageIcon, Plus, ShoppingCart } from 'lucide-react';
5
+ import { Link, useRouter } from '@/core/lib/navigation';
6
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
7
+ import type { Product } from 'brainerce';
8
+ import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
9
+ import { useTranslations } from '@/core/lib/translations';
10
+ import { PriceDisplay } from '@/ui/product/price-display';
11
+ import { StockBadge } from '@/ui/product/stock-badge';
12
+ import { DiscountBadge } from '@/ui/product/discount-badge';
13
+ import { Badge } from '@/components/ui/badge';
14
+ import { Button } from '@/components/ui/button';
15
+ import { Card } from '@/components/ui/card';
16
+ import { useCart } from '@/core/providers/store-provider';
17
+ import { useCurrency } from '@/core/lib/use-currency';
18
+ import { cn } from '@/core/lib/utils';
19
+
20
+ interface ProductCardProps {
21
+ product: Product;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * Pick the price + currency to render for a given product (PRD §23 FX overlay).
27
+ *
28
+ * When the storefront passed `regionId` to `getProducts({ regionId })` AND the
29
+ * region currency differs from the store currency, the backend attaches
30
+ * additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
31
+ * to the canonical store-currency `basePrice` / `salePrice`. Either way the
32
+ * cart still charges in the store currency — this only affects display.
33
+ */
34
+ function pickDisplayPrice(
35
+ product: Product,
36
+ fallbackCurrency: string
37
+ ): { price: number | undefined; salePrice: number | null; currency: string } {
38
+ if (product.displayPrice != null && product.displayCurrency) {
39
+ const sale =
40
+ product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
41
+ return {
42
+ // `displayPrice` is the base price converted to the region currency —
43
+ // it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
44
+ price: parseFloat(product.displayPrice),
45
+ salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
46
+ currency: product.displayCurrency,
47
+ };
48
+ }
49
+ // Same-currency fallback. `getProductPriceInfo.price` is the EFFECTIVE
50
+ // charged amount (= salePrice when on sale, base otherwise); `originalPrice`
51
+ // is the base. Map them to PriceDisplay's (price = base, salePrice = sale).
52
+ const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
53
+ return {
54
+ price: originalPrice,
55
+ salePrice: isOnSale ? effective : null,
56
+ currency: fallbackCurrency,
57
+ };
58
+ }
59
+
60
+ function VariantPriceRange({ product }: { product: Product }) {
61
+ const currency = useCurrency();
62
+
63
+ let min: number;
64
+ let max: number;
65
+ if (product.priceMin && product.priceMax) {
66
+ min = parseFloat(product.priceMin);
67
+ max = parseFloat(product.priceMax);
68
+ } else {
69
+ const variants = product.variants ?? [];
70
+ if (variants.length === 0) return null;
71
+ const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
72
+ min = Math.min(...prices);
73
+ max = Math.max(...prices);
74
+ }
75
+
76
+ if (isNaN(min) || isNaN(max)) return null;
77
+
78
+ return (
79
+ <span className="text-foreground text-sm font-medium">
80
+ {min === max
81
+ ? (formatPrice(min, { currency }) as string)
82
+ : `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
83
+ </span>
84
+ );
85
+ }
86
+
87
+ export function ProductCard({ product, className }: ProductCardProps) {
88
+ const t = useTranslations('common');
89
+ const tp = useTranslations('productDetail');
90
+ const tProd = useTranslations('products');
91
+ const router = useRouter();
92
+ const { refreshCart } = useCart();
93
+ const fallbackCurrency = useCurrency();
94
+ // FX overlay (PRD §23): prefer the region-converted display values when the
95
+ // storefront passed regionId to getProducts. Otherwise fall back to the
96
+ // canonical store-currency basePrice / salePrice.
97
+ const display = pickDisplayPrice(product, fallbackCurrency);
98
+ const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
99
+ const mainImage = product.images?.[0];
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
+ async function handleAddToCart(e: React.MouseEvent) {
110
+ e.preventDefault();
111
+ e.stopPropagation();
112
+
113
+ if (isVariable) {
114
+ router.push(`/products/${slug}`);
115
+ return;
116
+ }
117
+
118
+ if (adding || !canPurchase) return;
119
+
120
+ try {
121
+ setAdding(true);
122
+ const { getClient } = await import('@/core/lib/brainerce');
123
+ const client = getClient();
124
+ await client.smartAddToCart({ productId: product.id, quantity: 1 });
125
+ await refreshCart();
126
+ setAdded(true);
127
+ setTimeout(() => setAdded(false), 2000);
128
+ } catch (err) {
129
+ console.error('Failed to add to cart:', err);
130
+ } finally {
131
+ setAdding(false);
132
+ }
133
+ }
134
+
135
+ return (
136
+ <Card
137
+ className={cn(
138
+ 'group block overflow-hidden shadow-none transition-shadow hover:shadow-md',
139
+ className
140
+ )}
141
+ >
142
+ {/* Image — clickable */}
143
+ <Link href={`/products/${slug}`} className="block">
144
+ <div className="bg-muted relative aspect-square overflow-hidden">
145
+ {imageUrl ? (
146
+ <Image
147
+ src={imageUrl}
148
+ alt={mainImage?.alt || product.name}
149
+ fill
150
+ sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
151
+ className="object-cover transition-transform duration-300 group-hover:scale-105"
152
+ />
153
+ ) : (
154
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
155
+ <ImageIcon className="h-12 w-12" strokeWidth={1.5} aria-hidden="true" />
156
+ </div>
157
+ )}
158
+
159
+ {/* Badges */}
160
+ <div className="absolute start-2 top-2 flex flex-col items-start gap-1">
161
+ {isOnSale && (
162
+ <Badge variant="destructive" className="rounded px-2 py-1 font-bold">
163
+ {t('sale')}
164
+ </Badge>
165
+ )}
166
+ <DiscountBadge discount={product.discount} />
167
+ {product.isDownloadable && (
168
+ <Badge className="rounded px-2 py-1 font-bold">{tp('digitalProduct')}</Badge>
169
+ )}
170
+ </div>
171
+
172
+ {/* Add to cart overlay button */}
173
+ {(isVariable || canPurchase) && (
174
+ <Button
175
+ size="icon"
176
+ onClick={handleAddToCart}
177
+ disabled={adding}
178
+ aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
179
+ className={cn(
180
+ 'absolute bottom-2 end-2 h-8 w-8 rounded-full shadow-md transition-all',
181
+ 'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
182
+ added && 'bg-green-500 text-white hover:bg-green-500'
183
+ )}
184
+ >
185
+ {added ? (
186
+ <Check strokeWidth={2.5} aria-hidden="true" />
187
+ ) : isVariable ? (
188
+ <Plus aria-hidden="true" />
189
+ ) : (
190
+ <ShoppingCart aria-hidden="true" />
191
+ )}
192
+ </Button>
193
+ )}
194
+ </div>
195
+ </Link>
196
+
197
+ {/* Content */}
198
+ <div className="space-y-2 p-3">
199
+ {/* Categories */}
200
+ {product.categories && product.categories.length > 0 && (
201
+ <div className="flex flex-wrap gap-1">
202
+ {product.categories.slice(0, 2).map((cat) => (
203
+ <Badge
204
+ key={cat.id}
205
+ variant="secondary"
206
+ className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-normal"
207
+ >
208
+ {cat.name}
209
+ </Badge>
210
+ ))}
211
+ </div>
212
+ )}
213
+
214
+ {/* Name — clickable */}
215
+ <Link href={`/products/${slug}`}>
216
+ <h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
217
+ {product.name}
218
+ </h3>
219
+ </Link>
220
+
221
+ {/* Price */}
222
+ {isVariable ? (
223
+ <VariantPriceRange product={product} />
224
+ ) : (
225
+ <PriceDisplay
226
+ price={display.price ?? originalPrice}
227
+ salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
228
+ currency={display.currency}
229
+ size="sm"
230
+ />
231
+ )}
232
+
233
+ {/* Stock */}
234
+ <StockBadge inventory={product.inventory} />
235
+ </div>
236
+ </Card>
237
+ );
238
+ }