create-brainerce-store 1.50.5 → 1.51.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 (186) hide show
  1. package/dist/index.js +171 -66
  2. package/messages/en.json +4 -1
  3. package/messages/he.json +4 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.claude/commands/design.md +51 -0
  6. package/templates/nextjs/base/.env.local.ejs +5 -0
  7. package/templates/nextjs/base/.eslintrc.json +57 -3
  8. package/templates/nextjs/base/AGENTS.md +41 -0
  9. package/templates/nextjs/base/AI-GUIDE.md +94 -0
  10. package/templates/nextjs/base/CLAUDE.md +42 -0
  11. package/templates/nextjs/base/TRANSLATIONS.md +1 -1
  12. package/templates/nextjs/base/next.config.ts +2 -2
  13. package/templates/nextjs/base/src/app/account/page.tsx +5 -5
  14. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
  15. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
  16. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
  17. package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
  18. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
  19. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
  20. package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
  21. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
  22. package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
  23. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
  24. package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
  25. package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
  26. package/templates/nextjs/base/src/app/login/page.tsx +4 -4
  27. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
  28. package/templates/nextjs/base/src/app/page.tsx +4 -4
  29. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
  30. package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
  31. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
  32. package/templates/nextjs/base/src/app/products/page.tsx +4 -623
  33. package/templates/nextjs/base/src/app/register/page.tsx +4 -4
  34. package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
  35. package/templates/nextjs/base/src/app/sitemap.ts +1 -1
  36. package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
  37. package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
  38. package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
  39. package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
  40. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
  41. package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
  42. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
  43. package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
  44. package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
  45. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
  46. package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
  47. package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
  48. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
  49. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
  50. package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
  51. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
  52. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +6 -6
  53. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
  54. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
  55. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
  56. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
  57. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
  58. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
  59. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
  60. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
  61. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
  62. package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
  63. package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
  64. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
  65. package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
  66. package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
  67. package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
  68. package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
  69. package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
  70. package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +5 -5
  71. package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +6 -6
  72. package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +1 -1
  73. package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +4 -4
  74. package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +5 -5
  75. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +149 -0
  76. package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +4 -4
  77. package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +4 -4
  78. package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +2 -2
  79. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
  80. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
  81. package/templates/nextjs/base/src/ui/home/hero-section.tsx +29 -0
  82. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
  83. package/templates/nextjs/base/src/{components/content → ui/layout}/faq-section.tsx.ejs +2 -2
  84. package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +3 -3
  85. package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
  86. package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +1 -1
  87. package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +18 -51
  88. package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +1 -1
  89. package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +6 -6
  90. package/templates/nextjs/base/src/{components/products → ui/product}/modifier-group-selector.tsx +10 -73
  91. package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +2 -2
  92. package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +10 -10
  93. package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +45 -206
  94. package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
  95. package/templates/nextjs/base/src/ui/product/product-listing.tsx +429 -0
  96. package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +4 -4
  97. package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +2 -2
  98. package/templates/nextjs/base/src/{components/reviews → ui/product}/reviews-section.tsx +1 -1
  99. package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +2 -2
  100. package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
  101. package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
  102. package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
  103. package/templates/nextjs/base/tailwind.config.ts +12 -0
  104. package/templates/nextjs/designs/passepartout/app-overlay/layout.tsx.ejs +253 -0
  105. package/templates/nextjs/designs/passepartout/globals.css +412 -0
  106. package/templates/nextjs/designs/passepartout/messages-patch/en.json +87 -0
  107. package/templates/nextjs/designs/passepartout/messages-patch/he.json +87 -0
  108. package/templates/nextjs/designs/passepartout/ui/cart/cart-bundle-offer.tsx +111 -0
  109. package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +39 -0
  110. package/templates/nextjs/designs/passepartout/ui/cart/cart-upgrade-banner.tsx +141 -0
  111. package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +149 -0
  112. package/templates/nextjs/designs/passepartout/ui/cart/free-shipping-bar.tsx +62 -0
  113. package/templates/nextjs/designs/passepartout/ui/cart/reservation-countdown.tsx +103 -0
  114. package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +401 -0
  115. package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +139 -0
  116. package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +45 -0
  117. package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +103 -0
  118. package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +159 -0
  119. package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +155 -0
  120. package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +60 -0
  121. package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +34 -0
  122. package/templates/nextjs/designs/passepartout/ui/product/discount-badge.tsx +22 -0
  123. package/templates/nextjs/designs/passepartout/ui/product/frequently-bought-together.tsx +205 -0
  124. package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +111 -0
  125. package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +265 -0
  126. package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +667 -0
  127. package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +35 -0
  128. package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +427 -0
  129. package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +113 -0
  130. package/templates/nextjs/designs/passepartout/ui/product/review-form.tsx +294 -0
  131. package/templates/nextjs/designs/passepartout/ui/product/reviews-section.tsx.ejs +96 -0
  132. package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +52 -0
  133. package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +307 -0
  134. package/templates/nextjs/designs/passepartout/ui/shared/fly-to-cart.ts +85 -0
  135. package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +33 -0
  136. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +87 -0
  137. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +121 -0
  138. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +25 -0
  139. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +97 -0
  140. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +103 -0
  141. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
  142. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +117 -0
  143. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +52 -0
  144. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +79 -0
  145. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
  146. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
  147. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
  148. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
  149. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
  150. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
  151. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
  152. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +74 -0
  153. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +34 -0
  154. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
  155. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
  156. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +443 -0
  157. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +17 -0
  158. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +166 -0
  159. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +163 -0
  160. package/templates/nextjs/ui-canvas/product/price-display.tsx +50 -0
  161. package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
  162. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
  163. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
  164. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
  165. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +95 -0
  166. package/templates/nextjs/ui-canvas/product/review-form.tsx +269 -0
  167. package/templates/nextjs/ui-canvas/product/reviews-section.tsx +78 -0
  168. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +43 -0
  169. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +210 -0
  170. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +29 -0
  171. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +25 -0
  172. package/templates/nextjs/base/src/app/home-client.tsx +0 -98
  173. /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
  174. /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
  175. /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
  176. /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
  177. /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
  178. /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
  179. /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
  180. /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
  181. /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
  182. /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
  183. /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
  184. /package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +0 -0
  185. /package/templates/nextjs/base/src/{components → ui}/layout/language-switcher.tsx.ejs +0 -0
  186. /package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +0 -0
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+
3
+ import { useTranslations } from '@/core/lib/translations';
4
+
5
+ /**
6
+ * Floating WhatsApp contact — a common e-commerce service convention.
7
+ * Sits above the store-bot bubble on the end side (visually left in RTL).
8
+ * WhatsApp green is kept deliberately: recognizability beats palette here.
9
+ *
10
+ * Renders ONLY when `NEXT_PUBLIC_WHATSAPP_PHONE` is set (digits, international
11
+ * format without "+", e.g. 14155550123) — see `.env.local`. The pre-filled
12
+ * message comes from the locale messages (`common.whatsappMessage`).
13
+ */
14
+ const PHONE = process.env.NEXT_PUBLIC_WHATSAPP_PHONE;
15
+
16
+ export function WhatsAppButton() {
17
+ const t = useTranslations('common');
18
+
19
+ if (!PHONE) return null;
20
+
21
+ return (
22
+ <a
23
+ href={`https://wa.me/${PHONE}?text=${encodeURIComponent(t('whatsappMessage'))}`}
24
+ target="_blank"
25
+ rel="noopener noreferrer"
26
+ aria-label="WhatsApp"
27
+ className="fixed bottom-20 end-4 z-40 flex h-11 w-11 items-center justify-center rounded-full bg-[#25D366] text-white shadow-[0_8px_20px_-8px_rgba(0,0,0,0.4)] transition-transform hover:scale-105"
28
+ >
29
+ <svg viewBox="0 0 24 24" fill="currentColor" className="h-6 w-6" aria-hidden="true">
30
+ <path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.52.149-.174.198-.298.297-.497.1-.198.05-.371-.025-.52-.074-.149-.668-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
31
+ </svg>
32
+ </a>
33
+ );
34
+ }
@@ -0,0 +1,22 @@
1
+ import type { ProductDiscount } from 'brainerce';
2
+ import { cn } from '@/core/lib/utils';
3
+
4
+ interface DiscountBadgeProps {
5
+ discount?: ProductDiscount | null;
6
+ className?: string;
7
+ }
8
+
9
+ export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
10
+ if (!discount) return null;
11
+
12
+ return (
13
+ <span
14
+ className={cn(
15
+ 'text-destructive inline-flex items-center text-[0.6875rem] font-semibold tracking-[0.08em]',
16
+ className
17
+ )}
18
+ >
19
+ {discount.badgeText}
20
+ </span>
21
+ );
22
+ }
@@ -0,0 +1,205 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { Product, ProductRecommendation } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { useCart, useStoreInfo } from '@/core/providers/store-provider';
8
+ import { useCurrency } from '@/core/lib/use-currency';
9
+ import { useTranslations } from '@/core/lib/translations';
10
+ import { cn } from '@/core/lib/utils';
11
+
12
+ interface FrequentlyBoughtTogetherProps {
13
+ items: ProductRecommendation[];
14
+ currentProduct: Product;
15
+ className?: string;
16
+ }
17
+
18
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
19
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
20
+ const base = parseFloat(item.basePrice);
21
+ return sale != null && sale < base ? sale : base;
22
+ }
23
+
24
+ function ProductThumb({
25
+ name,
26
+ imageUrl,
27
+ price,
28
+ currency,
29
+ checked,
30
+ onToggle,
31
+ disabled,
32
+ }: {
33
+ name: string;
34
+ imageUrl: string | null;
35
+ price: number;
36
+ currency: string;
37
+ checked: boolean;
38
+ onToggle?: () => void;
39
+ disabled?: boolean;
40
+ }) {
41
+ return (
42
+ <label
43
+ className={cn(
44
+ 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-none border p-3 transition-all',
45
+ checked ? 'ring-primary ring-2' : 'opacity-60',
46
+ disabled && 'pointer-events-none'
47
+ )}
48
+ >
49
+ {onToggle && (
50
+ <input
51
+ type="checkbox"
52
+ checked={checked}
53
+ onChange={onToggle}
54
+ className="absolute start-2 top-2 h-4 w-4 rounded"
55
+ />
56
+ )}
57
+ <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
58
+ {imageUrl ? (
59
+ <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
60
+ ) : (
61
+ <div className="flex h-full w-full items-center justify-center">
62
+ <svg
63
+ className="text-muted-foreground h-8 w-8"
64
+ fill="none"
65
+ viewBox="0 0 24 24"
66
+ stroke="currentColor"
67
+ >
68
+ <path
69
+ strokeLinecap="round"
70
+ strokeLinejoin="round"
71
+ strokeWidth={1.5}
72
+ 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"
73
+ />
74
+ </svg>
75
+ </div>
76
+ )}
77
+ </div>
78
+ <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
79
+ <span className="text-muted-foreground mt-1 text-xs">
80
+ {formatPrice(price, { currency }) as string}
81
+ </span>
82
+ </label>
83
+ );
84
+ }
85
+
86
+ export function FrequentlyBoughtTogether({
87
+ items,
88
+ currentProduct,
89
+ className,
90
+ }: FrequentlyBoughtTogetherProps) {
91
+ // Hooks must be called unconditionally and in the same order on every
92
+ // render — keep all of them above any early `return null` branch.
93
+ const { storeInfo } = useStoreInfo();
94
+ const { refreshCart } = useCart();
95
+ const t = useTranslations('productDetail');
96
+ const currency = useCurrency();
97
+
98
+ // Only show up to 3 cross-sells
99
+ const crossSells = items.slice(0, 3);
100
+
101
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
102
+ const [adding, setAdding] = useState(false);
103
+
104
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
105
+ if (crossSells.length === 0) return null;
106
+
107
+ const currentPrice = getEffectivePrice(currentProduct);
108
+ const currentImage = currentProduct.images?.[0];
109
+ const currentImageUrl = currentImage
110
+ ? typeof currentImage === 'string'
111
+ ? currentImage
112
+ : currentImage.url
113
+ : null;
114
+
115
+ const totalPrice = crossSells
116
+ .filter((item) => selected.has(item.id))
117
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
118
+
119
+ const toggleItem = (id: string) => {
120
+ setSelected((prev) => {
121
+ const next = new Set(prev);
122
+ if (next.has(id)) {
123
+ next.delete(id);
124
+ } else {
125
+ next.add(id);
126
+ }
127
+ return next;
128
+ });
129
+ };
130
+
131
+ async function handleAddAll() {
132
+ if (adding || selected.size === 0) return;
133
+ try {
134
+ setAdding(true);
135
+ const { getClient } = await import('@/core/lib/brainerce');
136
+ const client = getClient();
137
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
138
+ for (const item of selectedItems) {
139
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
140
+ }
141
+ await refreshCart();
142
+ } catch (err) {
143
+ console.error('Failed to add items to cart:', err);
144
+ } finally {
145
+ setAdding(false);
146
+ }
147
+ }
148
+
149
+ return (
150
+ <div className={cn('border-border border p-6', className)}>
151
+ <p className="placard tabular">{t('fbtKicker')}</p>
152
+ <h2 className="text-foreground mt-1 font-serif text-xl font-normal">
153
+ {t('frequentlyBoughtTogether')}
154
+ </h2>
155
+ <div className="border-border mb-6 mt-5 border-b" />
156
+
157
+ <div className="flex flex-wrap items-center gap-3">
158
+ {/* Current product (always included, no checkbox) */}
159
+ <ProductThumb
160
+ name={currentProduct.name}
161
+ imageUrl={currentImageUrl}
162
+ price={currentPrice}
163
+ currency={currency}
164
+ checked={true}
165
+ disabled
166
+ />
167
+
168
+ {crossSells.map((item) => {
169
+ const img = item.images?.[0];
170
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
171
+ return (
172
+ <div key={item.id} className="flex items-center gap-3">
173
+ <span className="text-accent text-lg font-light">+</span>
174
+ <ProductThumb
175
+ name={item.name}
176
+ imageUrl={imgUrl}
177
+ price={getEffectivePrice(item)}
178
+ currency={currency}
179
+ checked={selected.has(item.id)}
180
+ onToggle={() => toggleItem(item.id)}
181
+ />
182
+ </div>
183
+ );
184
+ })}
185
+ </div>
186
+
187
+ {/* Total + Add button */}
188
+ <div className="mt-6 flex flex-wrap items-center gap-4">
189
+ <span className="text-foreground tabular text-lg">
190
+ {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
191
+ </span>
192
+ <button
193
+ onClick={handleAddAll}
194
+ disabled={adding || selected.size === 0}
195
+ className={cn(
196
+ 'bg-primary text-primary-foreground px-5 py-2.5 text-[0.8125rem] font-semibold transition-colors',
197
+ 'disabled:cursor-not-allowed disabled:opacity-50'
198
+ )}
199
+ >
200
+ {adding ? t('addingAll') : t('addSelectedToCart')}
201
+ </button>
202
+ </div>
203
+ </div>
204
+ );
205
+ }
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { useCurrency } from '@/core/lib/use-currency';
5
+ import { cn } from '@/core/lib/utils';
6
+
7
+ interface PriceDisplayProps {
8
+ price: string | number;
9
+ salePrice?: string | number | null;
10
+ currency?: string;
11
+ className?: string;
12
+ size?: 'sm' | 'md' | 'lg';
13
+ }
14
+
15
+ const sizeClasses = {
16
+ sm: 'text-sm',
17
+ md: 'text-base',
18
+ lg: 'text-[1.375rem]',
19
+ };
20
+
21
+ /**
22
+ * Split a formatted price into currency symbol and digits so the symbol can
23
+ * be set small and quiet — a discreet price tag ('₪ 1,240').
24
+ * Falls back to the raw string when the shape is unexpected.
25
+ */
26
+ function splitFormatted(formatted: string): { pre: string; num: string; post: string } | null {
27
+ const m = formatted.match(/^([^\d-]*)(-?\d[\d.,  \s]*)([^\d]*)$/);
28
+ if (!m) return null;
29
+ return { pre: m[1].trim(), num: m[2].trim(), post: m[3].trim() };
30
+ }
31
+
32
+ function PriceTag({
33
+ amount,
34
+ currency,
35
+ digitsClass,
36
+ symbolClass,
37
+ raised,
38
+ className,
39
+ }: {
40
+ amount: number;
41
+ currency: string;
42
+ digitsClass: string;
43
+ symbolClass: string;
44
+ /** PDP-scale auction-catalogue treatment: symbol raised 0.15em. */
45
+ raised?: boolean;
46
+ className?: string;
47
+ }) {
48
+ const formatted = formatPrice(amount, { currency }) as string;
49
+ const parts = splitFormatted(formatted);
50
+
51
+ if (!parts) {
52
+ return <span className={cn('tabular', digitsClass, className)}>{formatted}</span>;
53
+ }
54
+
55
+ const symbol = cn(symbolClass, raised && 'relative -top-[0.15em]');
56
+
57
+ return (
58
+ <span className={cn('tabular inline-flex items-baseline gap-1', className)}>
59
+ {parts.pre ? <span className={symbol}>{parts.pre}</span> : null}
60
+ <span className={digitsClass}>{parts.num}</span>
61
+ {parts.post ? <span className={symbol}>{parts.post}</span> : null}
62
+ </span>
63
+ );
64
+ }
65
+
66
+ export function PriceDisplay({
67
+ price,
68
+ salePrice,
69
+ currency,
70
+ className,
71
+ size = 'md',
72
+ }: PriceDisplayProps) {
73
+ const currencyCode = useCurrency(currency);
74
+
75
+ const basePrice = typeof price === 'string' ? parseFloat(price) : price;
76
+ const sale =
77
+ salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
78
+ const isOnSale = sale !== null && sale < basePrice;
79
+ const raised = size === 'lg';
80
+
81
+ return (
82
+ <span className={cn('inline-flex flex-wrap items-baseline gap-2', sizeClasses[size], className)}>
83
+ {isOnSale ? (
84
+ <>
85
+ <PriceTag
86
+ amount={sale!}
87
+ currency={currencyCode}
88
+ digitsClass="text-destructive"
89
+ symbolClass="text-accent text-[0.75em]"
90
+ raised={raised}
91
+ />
92
+ <PriceTag
93
+ amount={basePrice}
94
+ currency={currencyCode}
95
+ digitsClass="text-muted-foreground line-through"
96
+ symbolClass="text-muted-foreground text-[0.75em]"
97
+ className="text-[0.85em] opacity-60"
98
+ />
99
+ </>
100
+ ) : (
101
+ <PriceTag
102
+ amount={basePrice}
103
+ currency={currencyCode}
104
+ digitsClass="text-foreground"
105
+ symbolClass="text-muted-foreground text-[0.75em]"
106
+ raised={raised}
107
+ />
108
+ )}
109
+ </span>
110
+ );
111
+ }
@@ -0,0 +1,265 @@
1
+ 'use client';
2
+
3
+ import { useRef, 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
+ import { flyToCart } from '@/ui/shared/fly-to-cart';
16
+
17
+ interface ProductCardProps {
18
+ product: Product;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * Pick the price + currency to render for a given product (PRD §23 FX overlay).
24
+ *
25
+ * When the storefront passed `regionId` to `getProducts({ regionId })` AND the
26
+ * region currency differs from the store currency, the backend attaches
27
+ * additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
28
+ * to the canonical store-currency `basePrice` / `salePrice`. Either way the
29
+ * cart still charges in the store currency — this only affects display.
30
+ */
31
+ function pickDisplayPrice(
32
+ product: Product,
33
+ fallbackCurrency: string
34
+ ): { price: number | undefined; salePrice: number | null; currency: string } {
35
+ if (product.displayPrice != null && product.displayCurrency) {
36
+ const sale =
37
+ product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
38
+ return {
39
+ // `displayPrice` is the base price converted to the region currency —
40
+ // it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
41
+ price: parseFloat(product.displayPrice),
42
+ salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
43
+ currency: product.displayCurrency,
44
+ };
45
+ }
46
+ // Same-currency fallback. `getProductPriceInfo.price` is the EFFECTIVE
47
+ // charged amount (= salePrice when on sale, base otherwise); `originalPrice`
48
+ // is the base. Map them to PriceDisplay's (price = base, salePrice = sale).
49
+ const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
50
+ return {
51
+ price: originalPrice,
52
+ salePrice: isOnSale ? effective : null,
53
+ currency: fallbackCurrency,
54
+ };
55
+ }
56
+
57
+ function VariantPriceRange({ product }: { product: Product }) {
58
+ const currency = useCurrency();
59
+
60
+ let min: number;
61
+ let max: number;
62
+ if (product.priceMin && product.priceMax) {
63
+ min = parseFloat(product.priceMin);
64
+ max = parseFloat(product.priceMax);
65
+ } else {
66
+ const variants = product.variants ?? [];
67
+ if (variants.length === 0) return null;
68
+ const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
69
+ min = Math.min(...prices);
70
+ max = Math.max(...prices);
71
+ }
72
+
73
+ if (isNaN(min) || isNaN(max)) return null;
74
+
75
+ return (
76
+ <span className="text-foreground tabular text-sm">
77
+ {min === max
78
+ ? (formatPrice(min, { currency }) as string)
79
+ : `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
80
+ </span>
81
+ );
82
+ }
83
+
84
+ export function ProductCard({ product, className }: ProductCardProps) {
85
+ const t = useTranslations('common');
86
+ const tp = useTranslations('productDetail');
87
+ const tProd = useTranslations('products');
88
+ const router = useRouter();
89
+ const { refreshCart } = useCart();
90
+ const fallbackCurrency = useCurrency();
91
+ // FX overlay (PRD §23): prefer the region-converted display values when the
92
+ // storefront passed regionId to getProducts. Otherwise fall back to the
93
+ // canonical store-currency basePrice / salePrice.
94
+ const display = pickDisplayPrice(product, fallbackCurrency);
95
+ const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
96
+ const mainImage = product.images?.[0];
97
+ const imageUrl = mainImage?.url || null;
98
+ const slug = product.slug || product.id;
99
+ const isVariable = product.type === 'VARIABLE';
100
+
101
+ const [adding, setAdding] = useState(false);
102
+ const frameRef = useRef<HTMLDivElement>(null);
103
+ const [added, setAdded] = useState(false);
104
+
105
+ const canPurchase = product.inventory?.canPurchase !== false;
106
+
107
+ async function handleAddToCart(e: React.MouseEvent) {
108
+ e.preventDefault();
109
+ e.stopPropagation();
110
+
111
+ if (isVariable) {
112
+ router.push(`/products/${slug}`);
113
+ return;
114
+ }
115
+
116
+ if (adding || !canPurchase) return;
117
+
118
+ flyToCart(frameRef.current);
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
+ <div className={cn('group block', className)}>
137
+ {/* The framed work — pure paper mat behind the photo, 1px hairline frame */}
138
+ <Link href={`/products/${slug}`} className="block">
139
+ <div ref={frameRef} className="bg-surface border-border hung relative aspect-square overflow-hidden border">
140
+ {imageUrl ? (
141
+ <>
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-700 group-hover:scale-[1.03]"
148
+ />
149
+ {/* Second angle cross-fades in on hover — scale and fit at a glance */}
150
+ {product.images?.[1]?.url && (
151
+ <Image
152
+ src={product.images[1].url}
153
+ alt=""
154
+ fill
155
+ sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
156
+ className="object-cover opacity-0 transition-opacity duration-500 group-hover:opacity-100"
157
+ />
158
+ )}
159
+ </>
160
+ ) : (
161
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
162
+ <svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
163
+ <path
164
+ strokeLinecap="round"
165
+ strokeLinejoin="round"
166
+ strokeWidth={1.5}
167
+ 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"
168
+ />
169
+ </svg>
170
+ </div>
171
+ )}
172
+
173
+ {/* Placard notes inside the frame — plain text on the mat, never boxes */}
174
+ <div className="absolute start-3 top-3 flex flex-col gap-1">
175
+ {isOnSale && (
176
+ <span className="text-destructive text-[0.6875rem] font-semibold tracking-[0.08em]">
177
+ {t('sale')}
178
+ </span>
179
+ )}
180
+ <DiscountBadge discount={product.discount} />
181
+ {product.isDownloadable && (
182
+ <span className="text-muted-foreground text-[0.6875rem] tracking-[0.08em]">
183
+ {tp('digitalProduct')}
184
+ </span>
185
+ )}
186
+ </div>
187
+
188
+ {/* Add-to-cart strip — slides up inside the frame; always visible on touch */}
189
+ {(isVariable || canPurchase) && (
190
+ <button
191
+ onClick={handleAddToCart}
192
+ disabled={adding}
193
+ aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
194
+ className={cn(
195
+ 'bg-surface/95 border-border absolute inset-x-0 bottom-0 border-t py-2.5 text-center text-xs font-semibold transition-all duration-200',
196
+ 'translate-y-full opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
197
+ 'focus-visible:translate-y-0 focus-visible:opacity-100',
198
+ '[@media(hover:none)]:translate-y-0 [@media(hover:none)]:opacity-100',
199
+ 'text-foreground'
200
+ )}
201
+ >
202
+ {added ? (
203
+ <span className="inline-flex items-center justify-center gap-1.5">
204
+ <svg
205
+ className="text-accent h-3.5 w-3.5"
206
+ fill="none"
207
+ viewBox="0 0 24 24"
208
+ stroke="currentColor"
209
+ strokeWidth={1.5}
210
+ aria-hidden="true"
211
+ >
212
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
213
+ </svg>
214
+ {tp('addedToCart')}
215
+ </span>
216
+ ) : isVariable ? (
217
+ tProd('selectOptions')
218
+ ) : (
219
+ tp('addToCart')
220
+ )}
221
+ </button>
222
+ )}
223
+ </div>
224
+ </Link>
225
+
226
+ {/* The placard below the frame */}
227
+ <div className="space-y-1.5 pt-3 text-start">
228
+ {/* Category — first one only, quiet caption */}
229
+ {product.categories && product.categories.length > 0 && (
230
+ <p className="text-muted-foreground text-[0.6875rem] tracking-[0.08em]">
231
+ {product.categories[0].name}
232
+ </p>
233
+ )}
234
+
235
+ {/* Name — clickable, underline draws from the right */}
236
+ <Link href={`/products/${slug}`} className="group/name block">
237
+ <h3 className="text-foreground line-clamp-2 font-serif text-[0.9375rem] font-normal">
238
+ <span className="relative">
239
+ {product.name}
240
+ <span
241
+ aria-hidden="true"
242
+ className="bg-foreground absolute -bottom-0.5 start-0 end-0 h-px origin-right scale-x-0 transition-transform duration-200 group-hover/name:scale-x-100"
243
+ />
244
+ </span>
245
+ </h3>
246
+ </Link>
247
+
248
+ {/* Price */}
249
+ {isVariable ? (
250
+ <VariantPriceRange product={product} />
251
+ ) : (
252
+ <PriceDisplay
253
+ price={display.price ?? originalPrice}
254
+ salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
255
+ currency={display.currency}
256
+ size="sm"
257
+ />
258
+ )}
259
+
260
+ {/* Stock */}
261
+ <StockBadge inventory={product.inventory} />
262
+ </div>
263
+ </div>
264
+ );
265
+ }