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,62 @@
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { useStoreInfo, useCart } from '@/core/providers/store-provider';
5
+ import { useCurrency } from '@/core/lib/use-currency';
6
+ import { useTranslations } from '@/core/lib/translations';
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ interface FreeShippingBarProps {
10
+ className?: string;
11
+ }
12
+
13
+ export function FreeShippingBar({ className }: FreeShippingBarProps) {
14
+ // Hooks must be called unconditionally and in the same order on every
15
+ // render — keep all of them above any early `return null` branch.
16
+ const t = useTranslations('cart');
17
+ const { storeInfo } = useStoreInfo();
18
+ const { totals } = useCart();
19
+ const currency = useCurrency();
20
+
21
+ const upsell = storeInfo?.upsell;
22
+ const threshold = upsell?.freeShippingThreshold;
23
+ const enabled = upsell?.freeShippingBarEnabled !== false;
24
+
25
+ // Don't render if disabled or no threshold configured
26
+ if (!enabled || !threshold || threshold <= 0) return null;
27
+
28
+ const subtotal = totals.subtotal;
29
+ const remaining = Math.max(0, threshold - subtotal);
30
+ const progress = Math.min(100, (subtotal / threshold) * 100);
31
+ const qualified = remaining <= 0;
32
+
33
+ // Don't show if already qualified
34
+ if (qualified) {
35
+ return (
36
+ <div className={cn('rounded-none border border-green-200 bg-green-50 p-3', className)}>
37
+ <div className="flex items-center gap-2 text-sm font-medium text-green-700">
38
+ <svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
39
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
40
+ </svg>
41
+ {t('freeShippingQualified')}
42
+ </div>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <div className={cn('rounded-none border border-amber-200 bg-amber-50 p-3', className)}>
49
+ <p className="mb-2 text-sm text-amber-800">
50
+ {t('freeShippingRemaining', {
51
+ amount: formatPrice(remaining, { currency }) as string,
52
+ })}
53
+ </p>
54
+ <div className="h-2 w-full overflow-hidden rounded-full bg-amber-200">
55
+ <div
56
+ className="h-full rounded-full bg-amber-500 transition-all duration-500 ease-out"
57
+ style={{ width: `${progress}%` }}
58
+ />
59
+ </div>
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,103 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import type { ReservationInfo } from 'brainerce';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { cn } from '@/core/lib/utils';
7
+
8
+ interface ReservationCountdownProps {
9
+ reservation: ReservationInfo;
10
+ className?: string;
11
+ }
12
+
13
+ export function ReservationCountdown({ reservation, className }: ReservationCountdownProps) {
14
+ const t = useTranslations('reservation');
15
+ const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
16
+
17
+ const calculateRemaining = useCallback(() => {
18
+ if (!reservation.expiresAt) return 0;
19
+ const expiresAtMs = new Date(reservation.expiresAt).getTime();
20
+ const nowMs = Date.now();
21
+ return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
22
+ }, [reservation.expiresAt]);
23
+
24
+ useEffect(() => {
25
+ setRemainingSeconds(calculateRemaining());
26
+
27
+ const interval = setInterval(() => {
28
+ const remaining = calculateRemaining();
29
+ setRemainingSeconds(remaining);
30
+
31
+ if (remaining <= 0) {
32
+ clearInterval(interval);
33
+ }
34
+ }, 1000);
35
+
36
+ return () => clearInterval(interval);
37
+ }, [calculateRemaining]);
38
+
39
+ if (!reservation.hasReservation) return null;
40
+
41
+ const minutes = Math.floor(remainingSeconds / 60);
42
+ const seconds = remainingSeconds % 60;
43
+ const isExpired = remainingSeconds <= 0;
44
+ const isUrgent = remainingSeconds > 0 && remainingSeconds < 120;
45
+
46
+ const displayMessage = reservation.countdownMessage
47
+ ? reservation.countdownMessage.replace(
48
+ '{time}',
49
+ `${minutes}:${seconds.toString().padStart(2, '0')}`
50
+ )
51
+ : null;
52
+
53
+ return (
54
+ <div
55
+ className={cn(
56
+ 'flex items-center gap-3 rounded-none px-4 py-3 text-sm',
57
+ isExpired
58
+ ? 'bg-destructive/10 border-destructive/20 text-destructive border'
59
+ : isUrgent
60
+ ? 'border border-orange-200 bg-orange-50 text-orange-800 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300'
61
+ : 'bg-primary/5 border-primary/20 text-foreground border',
62
+ className
63
+ )}
64
+ >
65
+ <svg
66
+ className={cn(
67
+ 'h-5 w-5 flex-shrink-0',
68
+ isExpired
69
+ ? 'text-destructive'
70
+ : isUrgent
71
+ ? 'text-orange-600 dark:text-orange-400'
72
+ : 'text-primary'
73
+ )}
74
+ fill="none"
75
+ viewBox="0 0 24 24"
76
+ stroke="currentColor"
77
+ >
78
+ <path
79
+ strokeLinecap="round"
80
+ strokeLinejoin="round"
81
+ strokeWidth={2}
82
+ d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
83
+ />
84
+ </svg>
85
+
86
+ <div className="flex-1">
87
+ {isExpired ? (
88
+ <p className="font-medium">{t('expired')}</p>
89
+ ) : displayMessage ? (
90
+ <p>{displayMessage}</p>
91
+ ) : (
92
+ <p>
93
+ {isUrgent ? `${t('hurry')} ` : ''}
94
+ {t('reservedFor')}{' '}
95
+ <span className="font-semibold tabular-nums">
96
+ {minutes}:{seconds.toString().padStart(2, '0')}
97
+ </span>
98
+ </p>
99
+ )}
100
+ </div>
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,401 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import { Link } from '@/core/lib/navigation';
5
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
+ import { useStoreInfo } from '@/core/providers/store-provider';
7
+ import { useHomeData } from '@/core/hooks/use-home-data';
8
+ import { ProductGrid } from '@/ui/product/product-grid';
9
+ import { ProductCard } from '@/ui/product/product-card';
10
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
11
+ import { useTranslations } from '@/core/lib/translations';
12
+ import { TrustIcons } from '@/ui/layout/trust-icons';
13
+
14
+ /** Adds `revealed` to every [data-reveal] element as it enters the viewport. */
15
+ function useScrollReveal(ready: boolean) {
16
+ useEffect(() => {
17
+ if (!ready || typeof IntersectionObserver === 'undefined') return;
18
+ const els = document.querySelectorAll('[data-reveal]');
19
+ const io = new IntersectionObserver(
20
+ (entries) => {
21
+ for (const entry of entries) {
22
+ if (entry.isIntersecting) {
23
+ entry.target.classList.add('revealed');
24
+ io.unobserve(entry.target);
25
+ }
26
+ }
27
+ },
28
+ { threshold: 0.12 }
29
+ );
30
+ els.forEach((el) => io.observe(el));
31
+ return () => io.disconnect();
32
+ }, [ready]);
33
+ }
34
+
35
+ /** Catalog-numbered section header — the exhibition idiom. */
36
+ function SectionHeader({
37
+ kicker,
38
+ title,
39
+ end,
40
+ }: {
41
+ kicker: string;
42
+ title: string;
43
+ end?: React.ReactNode;
44
+ }) {
45
+ return (
46
+ <>
47
+ <div className="flex items-baseline justify-between">
48
+ <div>
49
+ <p className="placard tabular">{kicker}</p>
50
+ <h2 className="text-foreground mt-1 font-serif text-2xl font-normal">{title}</h2>
51
+ </div>
52
+ {end}
53
+ </div>
54
+ <div className="border-border mt-6 border-b" />
55
+ </>
56
+ );
57
+ }
58
+
59
+ export function HomeClient() {
60
+ const { storeInfo, loading: storeLoading } = useStoreInfo();
61
+ const { products, banners, loading } = useHomeData();
62
+ const [joined, setJoined] = useState(false);
63
+ const t = useTranslations('home');
64
+ const tc = useTranslations('common');
65
+
66
+ const storeName = storeInfo?.name || process.env.NEXT_PUBLIC_STORE_NAME || '';
67
+
68
+ const ready = !storeLoading && !loading;
69
+ useScrollReveal(ready);
70
+
71
+ // One tile per category, dressed with the first product photo we have for
72
+ // it. When the channel exposes no category data, the wall simply skips
73
+ // this section — never invent catalog content.
74
+ const categoryTiles = useMemo(() => {
75
+ const map = new Map<string, { name: string; image: string | null; href: string }>();
76
+ for (const p of products) {
77
+ for (const c of p.categories ?? []) {
78
+ if (!map.has(c.id)) {
79
+ map.set(c.id, {
80
+ name: c.name,
81
+ image: p.images?.[0]?.url ?? null,
82
+ href: `/products?category=${c.id}`,
83
+ });
84
+ }
85
+ }
86
+ }
87
+ return [...map.values()].filter((c) => c.image).slice(0, 4);
88
+ }, [products]);
89
+
90
+ // "On the wall now" — sale pieces first, newest fill the rest.
91
+ const wallStrip = useMemo(() => {
92
+ const sale = products.filter((p) => p.salePrice);
93
+ const rest = products.filter((p) => !p.salePrice);
94
+ return [...sale, ...rest].slice(0, 6);
95
+ }, [products]);
96
+
97
+ const heroA = products[0]?.images?.[0]?.url ?? null;
98
+ const heroB = products[2]?.images?.[0]?.url ?? null;
99
+ const storyImage =
100
+ products.find((p) => (p.images?.length ?? 0) > 2)?.images?.[1]?.url ??
101
+ products[1]?.images?.[0]?.url ??
102
+ null;
103
+
104
+ if (!ready) {
105
+ return (
106
+ <div className="flex min-h-[60vh] items-center justify-center">
107
+ <LoadingSpinner size="lg" />
108
+ </div>
109
+ );
110
+ }
111
+
112
+ const marqueeItems = [
113
+ t('marqueeHandmade'),
114
+ t('trustShipping'),
115
+ t('trustWarranty'),
116
+ t('trustGift'),
117
+ t('marqueeOneOff'),
118
+ ];
119
+
120
+ return (
121
+ <div>
122
+ {/* Discount banners — a quiet vernissage line, not a promo slab */}
123
+ {banners.length > 0 && (
124
+ <div className="bg-background border-border border-b">
125
+ <div className="mx-auto flex max-w-7xl flex-wrap items-center justify-center gap-x-3 gap-y-1 px-4 py-2.5 sm:px-6 lg:px-8">
126
+ {banners.map((banner, idx) => (
127
+ <span
128
+ key={banner.ruleId}
129
+ className="text-muted-foreground text-[0.6875rem] tracking-[0.08em]"
130
+ >
131
+ {idx > 0 ? (
132
+ <span aria-hidden="true" className="text-accent me-3">
133
+ ·
134
+ </span>
135
+ ) : null}
136
+ {banner.text}
137
+ </span>
138
+ ))}
139
+ </div>
140
+ </div>
141
+ )}
142
+
143
+ {/* Hero — editorial split: statement wall + hung collage */}
144
+ <section className="relative overflow-hidden">
145
+ <div aria-hidden="true">
146
+ <div className="wash wash-sand -top-40 start-[-12%] h-[36rem] w-[36rem]" />
147
+ <div className="wash wash-blush bottom-[-8rem] end-[-10%] h-[30rem] w-[30rem]" />
148
+ {/* The ghost word — the store's own name, printed into the wall */}
149
+ {storeName && (
150
+ <span className="text-foreground/[0.045] pointer-events-none absolute -bottom-16 start-2 select-none font-serif text-[clamp(7rem,20vw,17rem)] font-light leading-none">
151
+ {storeName}
152
+ </span>
153
+ )}
154
+ </div>
155
+ <div className="relative mx-auto grid max-w-7xl grid-cols-12 items-center gap-y-12 px-4 py-16 sm:px-6 md:min-h-[78vh] md:py-20 lg:px-8">
156
+ <div className="col-span-12 text-start md:col-span-5">
157
+ <p className="placard">{t('heroOverline')}</p>
158
+ <h1 className="text-foreground mt-5 font-serif text-[clamp(2.5rem,6.5vw,4.25rem)] font-light leading-[1.12]">
159
+ {storeName}
160
+ </h1>
161
+ <p className="text-foreground/70 mt-5 max-w-md font-serif text-lg font-light leading-relaxed">
162
+ {t('heroSubtitle')}
163
+ </p>
164
+ <div className="brass-rule mt-8 w-24" />
165
+ <div className="mt-10 flex flex-wrap items-center gap-4">
166
+ <Link
167
+ href="/products"
168
+ className="bg-primary text-primary-foreground inline-flex items-center px-8 py-3.5 text-[0.8125rem] font-semibold"
169
+ >
170
+ {tc('shopNow')}
171
+ </Link>
172
+ <Link href="/contact" className="text-foreground text-xs">
173
+ {t('ctaStory')} ←
174
+ </Link>
175
+ </div>
176
+ </div>
177
+
178
+ {/* The hung collage — two framed works, offset like a gallery wall */}
179
+ <div className="relative col-span-12 h-[46vh] md:col-span-7 md:h-[62vh]">
180
+ {heroA && (
181
+ <div className="bg-surface border-border hung absolute top-0 h-[78%] w-[68%] overflow-hidden border md:end-10">
182
+ <Image
183
+ src={heroA}
184
+ alt={products[0]?.name ?? ''}
185
+ fill
186
+ sizes="(max-width: 768px) 70vw, 40vw"
187
+ className="object-cover"
188
+ priority
189
+ />
190
+ </div>
191
+ )}
192
+ {heroB && (
193
+ <div className="bg-surface border-border hung absolute bottom-0 start-0 h-[52%] w-[44%] overflow-hidden border md:start-6">
194
+ <Image
195
+ src={heroB}
196
+ alt={products[2]?.name ?? ''}
197
+ fill
198
+ sizes="(max-width: 768px) 45vw, 25vw"
199
+ className="object-cover"
200
+ />
201
+ </div>
202
+ )}
203
+ <div className="brass-rule absolute -bottom-6 end-16 hidden w-24 md:block" />
204
+ </div>
205
+ </div>
206
+ </section>
207
+
208
+ {/* Marquee — the engraved drifting band */}
209
+ <div className="marquee border-border border-y py-3" aria-hidden="true">
210
+ <div className="marquee-track">
211
+ {[0, 1].map((half) => (
212
+ <span key={half} className="inline-flex">
213
+ {marqueeItems.map((item, i) => (
214
+ <span
215
+ key={`${half}-${i}`}
216
+ className="text-muted-foreground mx-6 inline-flex items-center gap-6 text-[0.6875rem] tracking-[0.14em]"
217
+ >
218
+ {item}
219
+ <span className="text-accent">◆</span>
220
+ </span>
221
+ ))}
222
+ </span>
223
+ ))}
224
+ </div>
225
+ </div>
226
+
227
+ {/* No. 01 — Collections */}
228
+ {categoryTiles.length > 0 && (
229
+ <section className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8" data-reveal>
230
+ <SectionHeader kicker={t('collectionsKicker')} title={t('collections')} />
231
+ <div className="mt-10 grid grid-cols-2 gap-x-4 gap-y-10 lg:grid-cols-4 lg:gap-x-6">
232
+ {categoryTiles.map((cat) => (
233
+ <Link key={cat.name} href={cat.href} className="group block">
234
+ <div className="bg-surface border-border hung relative aspect-[4/5] overflow-hidden border">
235
+ <Image
236
+ src={cat.image as string}
237
+ alt={cat.name}
238
+ fill
239
+ sizes="(max-width: 1024px) 50vw, 25vw"
240
+ className="object-cover transition-transform duration-700 group-hover:scale-[1.04]"
241
+ />
242
+ <div className="bg-surface/95 border-border absolute inset-x-0 bottom-0 border-t py-2.5 text-center">
243
+ <span className="text-foreground font-serif text-sm">{cat.name}</span>
244
+ </div>
245
+ </div>
246
+ </Link>
247
+ ))}
248
+ </div>
249
+ </section>
250
+ )}
251
+
252
+ <div className="hairline-fade" />
253
+
254
+ {/* No. 02 — Selected pieces */}
255
+ <section className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8" data-reveal>
256
+ <SectionHeader
257
+ kicker={t('featuredKicker')}
258
+ title={t('featuredProducts')}
259
+ end={
260
+ <Link href="/products" className="text-foreground text-xs">
261
+ {t('viewExhibition')} ←
262
+ </Link>
263
+ }
264
+ />
265
+ <div className="mt-10">
266
+ <ProductGrid products={products.slice(0, 8)} />
267
+ </div>
268
+ </section>
269
+
270
+ {/* The story — editorial band on the mat */}
271
+ <section className="bg-surface border-border border-y" data-reveal>
272
+ <div className="mx-auto grid max-w-7xl grid-cols-12 items-center gap-y-10 px-4 py-16 sm:px-6 md:py-24 lg:px-8">
273
+ <div className="col-span-12 md:col-span-5">
274
+ <p className="placard">{t('storyOverline')}</p>
275
+ <h2 className="text-foreground mt-4 font-serif text-[clamp(1.75rem,4vw,2.75rem)] font-light leading-[1.2]">
276
+ {t('storyTitle')}
277
+ </h2>
278
+ <p className="text-foreground/75 mt-6 max-w-md font-serif text-[1.0625rem] font-light leading-[1.9]">
279
+ {t('storyBody')}
280
+ </p>
281
+ <div className="brass-rule mt-8 w-16" />
282
+ <Link href="/products" className="text-foreground mt-8 inline-block text-xs">
283
+ {t('storyCta')} ←
284
+ </Link>
285
+ </div>
286
+ <div className="col-span-12 md:col-span-6 md:col-start-7">
287
+ {storyImage && (
288
+ <div className="bg-background border-border hung relative aspect-[4/3] overflow-hidden border">
289
+ <Image
290
+ src={storyImage}
291
+ alt={t('storyOverline')}
292
+ fill
293
+ sizes="(max-width: 768px) 100vw, 50vw"
294
+ className="object-cover"
295
+ />
296
+ </div>
297
+ )}
298
+ </div>
299
+ </div>
300
+ </section>
301
+
302
+ {/* No. 03 — On the wall now: horizontal snap strip */}
303
+ {wallStrip.length > 0 && (
304
+ <section className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8" data-reveal>
305
+ <SectionHeader kicker={t('onWallKicker')} title={t('onWall')} />
306
+ <div className="-mx-4 mt-10 flex snap-x snap-mandatory gap-5 overflow-x-auto px-4 pb-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
307
+ {wallStrip.map((p) => (
308
+ <div key={p.id} className="w-[70vw] flex-shrink-0 snap-start sm:w-[40vw] lg:w-[23%]">
309
+ <ProductCard product={p} />
310
+ </div>
311
+ ))}
312
+ </div>
313
+ </section>
314
+ )}
315
+
316
+ <div className="hairline-fade" />
317
+
318
+ {/* No. 04 — Kind words: quotes as placards */}
319
+ <section className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8" data-reveal>
320
+ <SectionHeader kicker={t('voicesKicker')} title={t('voices')} />
321
+ <div className="mt-10 grid grid-cols-1 gap-6 md:grid-cols-3">
322
+ {[1, 2, 3].map((n) => (
323
+ <figure key={n} className="bg-surface border-border hung border p-8">
324
+ <div className="text-accent text-lg" aria-hidden="true">
325
+ ★★★★★
326
+ </div>
327
+ <blockquote className="text-foreground/85 mt-4 font-serif text-[1.0625rem] font-light leading-[1.8]">
328
+ {t(`quote${n}` as 'quote1')}
329
+ </blockquote>
330
+ <figcaption className="placard mt-6">{t(`name${n}` as 'name1')}</figcaption>
331
+ </figure>
332
+ ))}
333
+ </div>
334
+ </section>
335
+
336
+ {/* Visit band — the closing wall */}
337
+ <section className="border-border relative overflow-hidden border-t" data-reveal>
338
+ <div aria-hidden="true">
339
+ <div className="wash wash-sand -top-24 end-[15%] h-[26rem] w-[26rem]" />
340
+ </div>
341
+ <div className="relative mx-auto max-w-7xl px-4 py-20 text-center sm:px-6 md:py-28 lg:px-8">
342
+ <p className="placard">{t('heroOverline')}</p>
343
+ <h2 className="text-foreground mt-4 font-serif text-[clamp(1.75rem,4.5vw,3rem)] font-light">
344
+ {t('visitTitle')}
345
+ </h2>
346
+ <p className="text-muted-foreground tabular mt-4 text-[0.8125rem] tracking-[0.08em]">
347
+ {t('visitSub')}
348
+ </p>
349
+ <div className="brass-rule mx-auto mt-8 w-24" />
350
+ <div className="mt-10 flex flex-wrap items-center justify-center gap-4">
351
+ <Link
352
+ href="/contact"
353
+ className="bg-primary text-primary-foreground inline-flex items-center px-8 py-3.5 text-[0.8125rem] font-semibold"
354
+ >
355
+ {t('visitCta')}
356
+ </Link>
357
+ <Link href="/products" className="text-foreground text-xs">
358
+ {t('viewExhibition')} ←
359
+ </Link>
360
+ </div>
361
+
362
+ {/* Newsletter — the guest book */}
363
+ <div className="mx-auto mt-14 max-w-md">
364
+ {joined ? (
365
+ <p className="border-border bg-surface text-foreground border py-4 text-center text-sm tracking-[0.04em]">
366
+ {t('newsletterSuccess')}
367
+ </p>
368
+ ) : (
369
+ <form
370
+ onSubmit={(e) => {
371
+ e.preventDefault();
372
+ setJoined(true);
373
+ }}
374
+ className="flex items-stretch"
375
+ >
376
+ <input
377
+ type="email"
378
+ required
379
+ placeholder={t('newsletterPlaceholder')}
380
+ className="border-border bg-surface min-w-0 flex-1 border px-4 py-3 text-sm focus:outline-none"
381
+ />
382
+ <button
383
+ type="submit"
384
+ className="bg-primary text-primary-foreground border-primary border px-6 text-[0.8125rem] font-semibold"
385
+ >
386
+ {t('newsletterCta')}
387
+ </button>
388
+ </form>
389
+ )}
390
+ {!joined && <p className="placard mt-3 text-center">{t('newsletterHook')}</p>}
391
+ </div>
392
+
393
+ {/* Trust icons — the promise row */}
394
+ <div className="mt-16">
395
+ <TrustIcons />
396
+ </div>
397
+ </div>
398
+ </section>
399
+ </div>
400
+ );
401
+ }