create-brainerce-store 1.51.0 → 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 (170) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +9 -68
  3. package/messages/en.json +32 -0
  4. package/messages/he.json +32 -0
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.claude/commands/design.md +9 -0
  7. package/templates/nextjs/base/AI-GUIDE.md +52 -3
  8. package/templates/nextjs/base/CLAUDE.md +10 -0
  9. package/templates/nextjs/base/package.json.ejs +52 -39
  10. package/templates/nextjs/base/src/app/globals.css +36 -30
  11. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
  12. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
  13. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
  14. package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
  15. package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
  16. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
  17. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
  18. package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
  19. package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
  20. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
  21. package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
  22. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
  23. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
  24. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
  25. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
  26. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
  27. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
  28. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
  29. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
  30. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
  31. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
  32. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
  33. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
  34. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
  35. package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
  37. package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
  38. package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
  39. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
  40. package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
  41. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
  42. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
  43. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
  44. package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
  45. package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
  46. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
  47. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
  48. package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
  49. package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
  50. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
  51. package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
  52. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
  53. package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
  54. package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
  55. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
  56. package/templates/nextjs/base/tailwind.config.ts +69 -42
  57. package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
  58. package/templates/nextjs/designs/atelier/globals.css +396 -0
  59. package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
  60. package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
  61. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
  62. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
  63. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
  64. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
  65. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
  66. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
  67. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
  68. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
  69. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
  70. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
  71. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
  72. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
  73. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
  74. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
  75. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
  76. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
  77. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
  78. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
  79. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
  80. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
  81. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
  82. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
  83. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
  84. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
  85. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
  86. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
  87. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
  88. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
  89. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
  90. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
  91. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
  92. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
  93. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
  94. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
  95. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
  96. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
  97. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
  98. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
  99. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
  100. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
  101. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
  102. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
  103. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
  104. package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
  105. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
  106. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
  107. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
  108. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
  109. package/templates/nextjs/themes/luxury/globals.css +405 -399
  110. package/templates/nextjs/themes/minimal/globals.css +36 -30
  111. package/templates/nextjs/themes/playful/globals.css +406 -400
  112. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
  113. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
  114. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
  115. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
  116. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
  117. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
  118. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
  119. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
  120. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
  121. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
  122. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
  123. package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
  124. package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
  125. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
  126. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
  127. package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
  128. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
  129. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
  130. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  131. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
  132. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
  133. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
  134. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
  135. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
  136. package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
  137. package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
  138. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
  139. package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
  140. package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
  141. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
  142. package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
  143. package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
  144. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
  145. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
  146. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
  147. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
  148. package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
  149. package/templates/nextjs/designs/passepartout/globals.css +0 -412
  150. package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
  151. package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
  152. package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
  153. package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
  154. package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
  155. package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
  156. package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
  157. package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
  158. package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
  159. package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
  160. package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
  161. package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
  162. package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
  163. package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
  164. package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
  165. package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
  166. package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
  167. package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
  168. package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
  169. package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
  170. package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
@@ -1,271 +1,238 @@
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 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('@/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
- <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
+ }