create-brainerce-store 1.72.0 → 1.74.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 (88) hide show
  1. package/dist/index.js +37 -4
  2. package/messages/en.json +30 -1
  3. package/messages/he.json +30 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/AGENTS.md.ejs +63 -16
  6. package/templates/nextjs/base/AI-GUIDE.md +17 -4
  7. package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
  8. package/templates/nextjs/base/TRANSLATIONS.md +207 -207
  9. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  10. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  11. package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
  12. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  13. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  15. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
  17. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
  18. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  19. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  20. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  22. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
  23. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  24. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  25. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  26. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  27. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  28. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  29. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  30. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  31. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  32. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
  33. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  34. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
  35. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
  37. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  38. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  39. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  40. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  41. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  42. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  43. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  44. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  45. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  46. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  47. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  48. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  49. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  50. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  51. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  52. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
  53. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
  54. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  55. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
  56. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
  57. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  58. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  59. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  60. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  61. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  62. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  63. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  64. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  65. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  66. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  67. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  68. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  69. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  70. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  71. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  72. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  73. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  74. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
  75. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  76. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
  77. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  78. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
  79. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  80. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  81. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  82. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  83. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  84. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  85. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  86. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  87. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
  88. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
@@ -1,174 +1,178 @@
1
- 'use client';
2
-
3
- import type { Product } from 'brainerce';
4
- import { Link } from '@/core/lib/navigation';
5
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
- import { useTranslations } from '@/core/lib/translations';
7
- import { PriceDisplay } from '@/ui/product/price-display';
8
- import { getProductPriceInfo } from 'brainerce';
9
- import { IconArrowEnd, IconStar, IconSparkle } from '@/ui/shared/icons';
10
-
11
- /**
12
- * Homepage hero — grid-breaking composition: oversized serif headline over a
13
- * soft forest/sand radial wash, live-catalog photography as overlapping,
14
- * slightly rotated floating cards with a glassy price chip. All imagery comes
15
- * from useHomeData() in the parent; motion is reduced-motion guarded.
16
- */
17
- export function HeroSection({
18
- storeName,
19
- products = [],
20
- }: {
21
- storeName?: string | null;
22
- products?: Product[];
23
- }) {
24
- const t = useTranslations('home');
25
- const tc = useTranslations('common');
26
-
27
- const heroProduct = products[0];
28
- const sideProduct = products[1];
29
- const heroImage = heroProduct?.images?.[0]?.url;
30
- const sideImage = sideProduct?.images?.[0]?.url;
31
-
32
- return (
33
- <section className="wash-hero relative overflow-hidden">
34
- {/* Drifting glow blob behind the collage */}
35
- <div aria-hidden="true" className="glow-blob blob-drift end-[8%] top-[18%] h-80 w-80" />
36
-
37
- <div className="container-page relative grid items-center gap-12 pb-20 pt-12 lg:grid-cols-12 lg:gap-8 lg:pb-28 lg:pt-20">
38
- {/* Copy */}
39
- <div className="reveal relative z-10 max-w-2xl lg:col-span-6">
40
- <span className="glass inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-xs font-semibold text-primary">
41
- <IconSparkle size={16} />
42
- {t('heroEyebrow')}
43
- </span>
44
- <h1 className="mt-6 text-[clamp(2.75rem,6.5vw,5.5rem)] leading-[1.04]">
45
- {t('heroTitle')}
46
- </h1>
47
- <p className="mt-6 max-w-lg text-lg leading-8 text-muted-foreground">{t('heroLead')}</p>
48
-
49
- <div className="mt-9 flex flex-wrap items-center gap-3">
50
- <Link href="/products" className="btn-primary btn-lg">
51
- {tc('shopNow')}
52
- <IconArrowEnd size={20} className="rtl-flip" />
53
- </Link>
54
- <a href="#categories" className="btn-outline btn-lg bg-background/60 backdrop-blur-sm">
55
- {t('heroCtaSecondary')}
56
- </a>
57
- </div>
58
-
59
- {/* Soft social proof */}
60
- <p className="mt-9 inline-flex items-center gap-2 text-sm text-muted-foreground">
61
- <span className="inline-flex text-primary" aria-hidden="true">
62
- {[1, 2, 3, 4, 5].map((n) => (
63
- <IconStar key={n} size={16} filled />
64
- ))}
65
- </span>
66
- {t('heroTrust')}
67
- </p>
68
- </div>
69
-
70
- {/* Overlapping floating collage — real catalog photography */}
71
- {heroImage && (
72
- <div className="reveal reveal-delay-1 relative mx-auto h-[26rem] w-full max-w-md sm:h-[30rem] lg:col-span-6 lg:h-[34rem] lg:max-w-none">
73
- {/* Main card tilted, floating */}
74
- <Link
75
- href={`/products/${heroProduct.slug || heroProduct.id}`}
76
- className="group float-slow absolute inset-y-0 start-[4%] block w-[72%] -rotate-2 overflow-hidden rounded-3xl border-4 border-background shadow-[0_36px_80px_-30px_hsl(var(--primary)/0.45)]"
77
- style={{ '--float-rotate': '-2deg' } as React.CSSProperties}
78
- >
79
- <Image
80
- src={heroImage}
81
- alt={heroProduct.images?.[0]?.alt || heroProduct.name}
82
- fill
83
- priority
84
- sizes="(max-width: 1024px) 80vw, 36vw"
85
- className="img-zoom object-cover"
86
- />
87
- {/* Glassy shop-this chip */}
88
- <span className="glass absolute bottom-5 end-5 start-5 flex items-center justify-between gap-3 px-4 py-3 sm:end-auto sm:min-w-[15rem]">
89
- <span className="min-w-0">
90
- <span className="block truncate text-sm font-semibold text-foreground">
91
- {heroProduct.name}
92
- </span>
93
- <HeroPrice product={heroProduct} />
94
- </span>
95
- <span
96
- aria-hidden="true"
97
- className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-[0_8px_20px_-6px_hsl(var(--primary)/0.6)]"
98
- >
99
- <IconArrowEnd size={18} className="rtl-flip" />
100
- </span>
101
- </span>
102
- </Link>
103
-
104
- {/* Secondary card — overlaps the main one, counter-tilted */}
105
- {sideImage && (
106
- <Link
107
- href={`/products/${sideProduct.slug || sideProduct.id}`}
108
- className="group float-slower absolute -end-2 top-[54%] block aspect-[3/4] w-[38%] rotate-3 overflow-hidden rounded-3xl border-4 border-background shadow-[0_28px_60px_-24px_hsl(var(--primary)/0.4)] sm:end-0"
109
- style={{ '--float-rotate': '3deg' } as React.CSSProperties}
110
- >
111
- <Image
112
- src={sideImage}
113
- alt={sideProduct.images?.[0]?.alt || sideProduct.name}
114
- fill
115
- sizes="(max-width: 1024px) 40vw, 16vw"
116
- className="img-zoom object-cover"
117
- />
118
- <span className="sr-only">{sideProduct.name}</span>
119
- </Link>
120
- )}
121
-
122
- {/* Floating accent chip */}
123
- <span
124
- aria-hidden="true"
125
- className="glass float-slower absolute -start-2 top-[12%] hidden -rotate-[4deg] items-center gap-2 px-4 py-2 text-sm font-semibold text-primary sm:inline-flex"
126
- style={{ '--float-rotate': '-4deg' } as React.CSSProperties}
127
- >
128
- <IconSparkle size={18} />
129
- {t('storyStat1Value')}
130
- </span>
131
- </div>
132
- )}
133
-
134
- {/* Empty catalog — the same floating composition as designed washes,
135
- so a brand-new store still opens rich instead of half-blank. The
136
- first two published products replace these automatically. */}
137
- {!heroImage && (
138
- <div
139
- aria-hidden="true"
140
- className="reveal reveal-delay-1 relative mx-auto h-[26rem] w-full max-w-md sm:h-[30rem] lg:col-span-6 lg:h-[34rem] lg:max-w-none"
141
- >
142
- <div
143
- className="float-slow wash-forest absolute inset-y-0 start-[4%] flex w-[72%] -rotate-2 items-center justify-center overflow-hidden rounded-3xl border-4 border-background shadow-[0_36px_80px_-30px_hsl(var(--primary)/0.45)]"
144
- style={{ '--float-rotate': '-2deg' } as React.CSSProperties}
145
- >
146
- <IconSparkle size={56} className="text-primary/25" />
147
- <span className="glass absolute bottom-5 end-5 start-5 px-4 py-3 text-center text-sm font-medium text-muted-foreground">
148
- {t('heroEmptyHint')}
149
- </span>
150
- </div>
151
- <div
152
- className="float-slower wash-sand absolute -end-2 top-[54%] flex aspect-[3/4] w-[38%] rotate-3 items-center justify-center rounded-3xl border-4 border-background shadow-[0_28px_60px_-24px_hsl(var(--primary)/0.4)] sm:end-0"
153
- style={{ '--float-rotate': '3deg' } as React.CSSProperties}
154
- >
155
- <IconStar size={32} filled className="text-primary/20" />
156
- </div>
157
- </div>
158
- )}
159
- </div>
160
- </section>
161
- );
162
- }
163
-
164
- function HeroPrice({ product }: { product: Product }) {
165
- const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
166
- return (
167
- <PriceDisplay
168
- price={originalPrice}
169
- salePrice={isOnSale ? price : undefined}
170
- size="sm"
171
- className="mt-0.5"
172
- />
173
- );
174
- }
1
+ 'use client';
2
+
3
+ import type { Product } from 'brainerce';
4
+ import { Link } from '@/core/lib/navigation';
5
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
+ import { useTranslations } from '@/core/lib/translations';
7
+ import { PriceDisplay } from '@/ui/product/price-display';
8
+ import { pickDisplayPrice } from '@/core/lib/display-price';
9
+ import { useCurrency } from '@/core/lib/use-currency';
10
+ import { IconArrowEnd, IconStar, IconSparkle } from '@/ui/shared/icons';
11
+
12
+ /**
13
+ * Homepage hero grid-breaking composition: oversized serif headline over a
14
+ * soft forest/sand radial wash, live-catalog photography as overlapping,
15
+ * slightly rotated floating cards with a glassy price chip. All imagery comes
16
+ * from useHomeData() in the parent; motion is reduced-motion guarded.
17
+ */
18
+ export function HeroSection({
19
+ storeName,
20
+ products = [],
21
+ }: {
22
+ storeName?: string | null;
23
+ products?: Product[];
24
+ }) {
25
+ const t = useTranslations('home');
26
+ const tc = useTranslations('common');
27
+
28
+ const heroProduct = products[0];
29
+ const sideProduct = products[1];
30
+ const heroImage = heroProduct?.images?.[0]?.url;
31
+ const sideImage = sideProduct?.images?.[0]?.url;
32
+
33
+ return (
34
+ <section className="wash-hero relative overflow-hidden">
35
+ {/* Drifting glow blob behind the collage */}
36
+ <div aria-hidden="true" className="glow-blob blob-drift end-[8%] top-[18%] h-80 w-80" />
37
+
38
+ <div className="container-page relative grid items-center gap-12 pb-20 pt-12 lg:grid-cols-12 lg:gap-8 lg:pb-28 lg:pt-20">
39
+ {/* Copy */}
40
+ <div className="reveal relative z-10 max-w-2xl lg:col-span-6">
41
+ <span className="glass inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-xs font-semibold text-primary">
42
+ <IconSparkle size={16} />
43
+ {t('heroEyebrow')}
44
+ </span>
45
+ <h1 className="mt-6 text-[clamp(2.75rem,6.5vw,5.5rem)] leading-[1.04]">
46
+ {t('heroTitle')}
47
+ </h1>
48
+ <p className="mt-6 max-w-lg text-lg leading-8 text-muted-foreground">{t('heroLead')}</p>
49
+
50
+ <div className="mt-9 flex flex-wrap items-center gap-3">
51
+ <Link href="/products" className="btn-primary btn-lg">
52
+ {tc('shopNow')}
53
+ <IconArrowEnd size={20} className="rtl-flip" />
54
+ </Link>
55
+ <a href="#categories" className="btn-outline btn-lg bg-background/60 backdrop-blur-sm">
56
+ {t('heroCtaSecondary')}
57
+ </a>
58
+ </div>
59
+
60
+ {/* Soft social proof */}
61
+ <p className="mt-9 inline-flex items-center gap-2 text-sm text-muted-foreground">
62
+ <span className="inline-flex text-primary" aria-hidden="true">
63
+ {[1, 2, 3, 4, 5].map((n) => (
64
+ <IconStar key={n} size={16} filled />
65
+ ))}
66
+ </span>
67
+ {t('heroTrust')}
68
+ </p>
69
+ </div>
70
+
71
+ {/* Overlapping floating collage — real catalog photography */}
72
+ {heroImage && (
73
+ <div className="reveal reveal-delay-1 relative mx-auto h-[26rem] w-full max-w-md sm:h-[30rem] lg:col-span-6 lg:h-[34rem] lg:max-w-none">
74
+ {/* Main card — tilted, floating */}
75
+ <Link
76
+ href={`/products/${heroProduct.slug || heroProduct.id}`}
77
+ className="group float-slow absolute inset-y-0 start-[4%] block w-[72%] -rotate-2 overflow-hidden rounded-3xl border-4 border-background shadow-[0_36px_80px_-30px_hsl(var(--primary)/0.45)]"
78
+ style={{ '--float-rotate': '-2deg' } as React.CSSProperties}
79
+ >
80
+ <Image
81
+ src={heroImage}
82
+ alt={heroProduct.images?.[0]?.alt || heroProduct.name}
83
+ fill
84
+ priority
85
+ sizes="(max-width: 1024px) 80vw, 36vw"
86
+ className="img-zoom object-cover"
87
+ />
88
+ {/* Glassy shop-this chip */}
89
+ <span className="glass absolute bottom-5 end-5 start-5 flex items-center justify-between gap-3 px-4 py-3 sm:end-auto sm:min-w-[15rem]">
90
+ <span className="min-w-0">
91
+ <span className="block truncate text-sm font-semibold text-foreground">
92
+ {heroProduct.name}
93
+ </span>
94
+ <HeroPrice product={heroProduct} />
95
+ </span>
96
+ <span
97
+ aria-hidden="true"
98
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-[0_8px_20px_-6px_hsl(var(--primary)/0.6)]"
99
+ >
100
+ <IconArrowEnd size={18} className="rtl-flip" />
101
+ </span>
102
+ </span>
103
+ </Link>
104
+
105
+ {/* Secondary card — overlaps the main one, counter-tilted */}
106
+ {sideImage && (
107
+ <Link
108
+ href={`/products/${sideProduct.slug || sideProduct.id}`}
109
+ className="group float-slower absolute -end-2 top-[54%] block aspect-[3/4] w-[38%] rotate-3 overflow-hidden rounded-3xl border-4 border-background shadow-[0_28px_60px_-24px_hsl(var(--primary)/0.4)] sm:end-0"
110
+ style={{ '--float-rotate': '3deg' } as React.CSSProperties}
111
+ >
112
+ <Image
113
+ src={sideImage}
114
+ alt={sideProduct.images?.[0]?.alt || sideProduct.name}
115
+ fill
116
+ sizes="(max-width: 1024px) 40vw, 16vw"
117
+ className="img-zoom object-cover"
118
+ />
119
+ <span className="sr-only">{sideProduct.name}</span>
120
+ </Link>
121
+ )}
122
+
123
+ {/* Floating accent chip */}
124
+ <span
125
+ aria-hidden="true"
126
+ className="glass float-slower absolute -start-2 top-[12%] hidden -rotate-[4deg] items-center gap-2 px-4 py-2 text-sm font-semibold text-primary sm:inline-flex"
127
+ style={{ '--float-rotate': '-4deg' } as React.CSSProperties}
128
+ >
129
+ <IconSparkle size={18} />
130
+ {t('storyStat1Value')}
131
+ </span>
132
+ </div>
133
+ )}
134
+
135
+ {/* Empty catalog the same floating composition as designed washes,
136
+ so a brand-new store still opens rich instead of half-blank. The
137
+ first two published products replace these automatically. */}
138
+ {!heroImage && (
139
+ <div
140
+ aria-hidden="true"
141
+ className="reveal reveal-delay-1 relative mx-auto h-[26rem] w-full max-w-md sm:h-[30rem] lg:col-span-6 lg:h-[34rem] lg:max-w-none"
142
+ >
143
+ <div
144
+ className="float-slow wash-forest absolute inset-y-0 start-[4%] flex w-[72%] -rotate-2 items-center justify-center overflow-hidden rounded-3xl border-4 border-background shadow-[0_36px_80px_-30px_hsl(var(--primary)/0.45)]"
145
+ style={{ '--float-rotate': '-2deg' } as React.CSSProperties}
146
+ >
147
+ <IconSparkle size={56} className="text-primary/25" />
148
+ <span className="glass absolute bottom-5 end-5 start-5 px-4 py-3 text-center text-sm font-medium text-muted-foreground">
149
+ {t('heroEmptyHint')}
150
+ </span>
151
+ </div>
152
+ <div
153
+ className="float-slower wash-sand absolute -end-2 top-[54%] flex aspect-[3/4] w-[38%] rotate-3 items-center justify-center rounded-3xl border-4 border-background shadow-[0_28px_60px_-24px_hsl(var(--primary)/0.4)] sm:end-0"
154
+ style={{ '--float-rotate': '3deg' } as React.CSSProperties}
155
+ >
156
+ <IconStar size={32} filled className="text-primary/20" />
157
+ </div>
158
+ </div>
159
+ )}
160
+ </div>
161
+ </section>
162
+ );
163
+ }
164
+
165
+ function HeroPrice({ product }: { product: Product }) {
166
+ // Region-aware, like every other Product price on the site. Without this the
167
+ // hero quotes the store currency while the grid below it quotes the region's.
168
+ const display = pickDisplayPrice(product, useCurrency());
169
+ return (
170
+ <PriceDisplay
171
+ price={display.price}
172
+ salePrice={display.salePrice ?? undefined}
173
+ currency={display.currency}
174
+ size="sm"
175
+ className="mt-0.5"
176
+ />
177
+ );
178
+ }
@@ -4,7 +4,6 @@ import { useStoreInfo } from '@/core/providers/store-provider';
4
4
  import { useHomeData } from '@/core/hooks/use-home-data';
5
5
  import { LoadingSpinner } from '@/ui/shared/loading-spinner';
6
6
  import { Reveal } from '@/ui/shared/reveal';
7
- import { DiscountBannerStrip } from './discount-banner-strip';
8
7
  import { HeroSection } from './hero-section';
9
8
  import { BenefitsBand } from './benefits-band';
10
9
  import { FeaturedProductsSection } from './featured-products-section';
@@ -15,13 +14,13 @@ import { NewsletterSection } from './newsletter-section';
15
14
 
16
15
  /**
17
16
  * Homepage composition — data via useHomeData() and useStoreInfo():
18
- * banners → hero (floating collage) → tilted values marquee → trust band →
19
- * featured grid → category tiles → studio story → testimonials → newsletter.
17
+ * hero (floating collage) → tilted values marquee → trust band →
18
+ * featured grid → category tiles → studio story → ratings → newsletter.
20
19
  * Sections fade up on scroll via <Reveal> (reduced-motion safe).
21
20
  */
22
21
  export function HomeClient() {
23
22
  const { storeInfo, loading: storeLoading } = useStoreInfo();
24
- const { products, banners, loading } = useHomeData();
23
+ const { products, loading } = useHomeData();
25
24
 
26
25
  if (storeLoading || loading) {
27
26
  return <LoadingSpinner size="lg" className="min-h-[70vh]" />;
@@ -29,7 +28,6 @@ export function HomeClient() {
29
28
 
30
29
  return (
31
30
  <div className="overflow-x-clip">
32
- <DiscountBannerStrip banners={banners} />
33
31
  <HeroSection storeName={storeInfo?.name} products={products} />
34
32
  <Reveal>
35
33
  <BenefitsBand />
@@ -44,7 +42,7 @@ export function HomeClient() {
44
42
  <EditorialBand />
45
43
  </Reveal>
46
44
  <Reveal>
47
- <TestimonialsBand />
45
+ <TestimonialsBand products={products} />
48
46
  </Reveal>
49
47
  <Reveal>
50
48
  <NewsletterSection />
@@ -1,78 +1,150 @@
1
- 'use client';
2
-
3
- /**
4
- * Newsletter signup band — forest brand block with an inline email form.
5
- * Client-side success state (template default; wire a provider later).
6
- */
7
- import * as React from 'react';
8
- import { useTranslations } from '@/core/lib/translations';
9
- import { IconMail, IconCheck } from '@/ui/shared/icons';
10
-
11
- export function NewsletterSection() {
12
- const t = useTranslations('home');
13
- const [email, setEmail] = React.useState('');
14
- const [done, setDone] = React.useState(false);
15
-
16
- function handleSubmit(e: React.FormEvent) {
17
- e.preventDefault();
18
- if (!email.trim()) return;
19
- setDone(true);
20
- }
21
-
22
- return (
23
- <section className="section-pad">
24
- <div className="container-page">
25
- <div className="wash-forest relative overflow-hidden rounded-3xl px-6 py-12 text-primary-foreground shadow-[0_30px_70px_-28px_hsl(var(--primary)/0.55)] sm:px-12 lg:px-20 lg:py-16">
26
- <div className="mx-auto flex max-w-2xl flex-col items-center text-center">
27
- <span
28
- aria-hidden="true"
29
- className="mb-5 flex h-12 w-12 items-center justify-center rounded-full bg-primary-foreground/10"
30
- >
31
- <IconMail size={24} />
32
- </span>
33
- <h2 className="text-4xl text-primary-foreground sm:text-5xl">{t('newsletterTitle')}</h2>
34
- <p className="mt-3 max-w-lg text-primary-foreground/75">{t('newsletterSubtitle')}</p>
35
-
36
- {done ? (
37
- <p
38
- role="status"
39
- className="mt-8 inline-flex items-center gap-2 rounded-full bg-primary-foreground/10 px-5 py-3 text-sm font-semibold"
40
- >
41
- <IconCheck size={18} />
42
- {t('newsletterSuccess')}
43
- </p>
44
- ) : (
45
- <form
46
- onSubmit={handleSubmit}
47
- className="mt-8 flex w-full max-w-md flex-col gap-3 sm:flex-row"
48
- >
49
- <label className="sr-only" htmlFor="newsletter-email">
50
- {t('newsletterPlaceholder')}
51
- </label>
52
- <input
53
- id="newsletter-email"
54
- type="email"
55
- required
56
- value={email}
57
- onChange={(e) => setEmail(e.target.value)}
58
- placeholder={t('newsletterPlaceholder')}
59
- className="input h-12 flex-1 border-transparent bg-background text-foreground"
60
- />
61
- <button
62
- type="submit"
63
- className="btn h-12 shrink-0 bg-background px-7 text-foreground hover:bg-background/90"
64
- >
65
- {t('newsletterCta')}
66
- </button>
67
- </form>
68
- )}
69
-
70
- {!done && (
71
- <p className="mt-3 text-xs text-primary-foreground/60">{t('newsletterPrivacy')}</p>
72
- )}
73
- </div>
74
- </div>
75
- </div>
76
- </section>
77
- );
78
- }
1
+ 'use client';
2
+
3
+ /**
4
+ * Newsletter signup band — forest brand block with an inline email form.
5
+ *
6
+ * ⛔ IT IS CONFIRMED OPT-IN AND THAT CANNOT BE TURNED OFF.
7
+ * `marketing.subscribe()` creates the contact and mails a confirmation link.
8
+ * The address is unmailable, and invisible to every campaign audience, until
9
+ * the recipient clicks it, which is why the success state says "check your
10
+ * email" and never "you're subscribed". The second is a lie until a click
11
+ * lands in a mailbox this code cannot see, and most people never click.
12
+ *
13
+ * THE RESPONSE CARRIES NO INFORMATION. `{ ok: true }` comes back identically
14
+ * for a new address, one that confirmed months ago, one inside its 24-hour
15
+ * resend cooldown, and one suppressed after a bounce. Deliberately so: a
16
+ * response that told them apart would turn this public form into a way to test
17
+ * whether a given person shops here. Render one success state and stop.
18
+ *
19
+ * `source` is the only signal the merchant gets about WHICH capture works, so
20
+ * it differs from the footer's. Rate limited to 3 requests / 60s per IP.
21
+ *
22
+ * See `ui/layout/newsletter-signup.tsx` for the footer's copy of this.
23
+ */
24
+ import * as React from 'react';
25
+ import { getClient } from '@/core/lib/brainerce';
26
+ import { useTranslations } from '@/core/lib/translations';
27
+ import { IconMail, IconCheck } from '@/ui/shared/icons';
28
+
29
+ export function NewsletterSection() {
30
+ const t = useTranslations('home');
31
+ const tn = useTranslations('newsletter');
32
+ const [email, setEmail] = React.useState('');
33
+ const [honeypot, setHoneypot] = React.useState('');
34
+ const [loading, setLoading] = React.useState(false);
35
+ const [done, setDone] = React.useState(false);
36
+ const [error, setError] = React.useState<string | null>(null);
37
+
38
+ // <html lang> is set from the active locale, and passing it decides the
39
+ // language of the confirmation email. Omit it and a Hebrew shopper gets
40
+ // English.
41
+ const locale = React.useMemo(() => {
42
+ if (typeof document !== 'undefined') return document.documentElement.lang || undefined;
43
+ return undefined;
44
+ }, []);
45
+
46
+ async function handleSubmit(e: React.FormEvent) {
47
+ e.preventDefault();
48
+ if (loading || !email.trim()) return;
49
+ setLoading(true);
50
+ setError(null);
51
+ try {
52
+ await getClient().marketing.subscribe({
53
+ email: email.trim(),
54
+ locale,
55
+ source: 'home-band',
56
+ honeypot,
57
+ });
58
+ setDone(true);
59
+ setEmail('');
60
+ } catch {
61
+ // A 429 lands here too. The copy stays generic rather than explaining
62
+ // the rate limit, which would only show an abuser where the edge is.
63
+ setError(tn('genericError'));
64
+ } finally {
65
+ setLoading(false);
66
+ }
67
+ }
68
+
69
+ return (
70
+ <section className="section-pad">
71
+ <div className="container-page">
72
+ <div className="wash-forest relative overflow-hidden rounded-3xl px-6 py-12 text-primary-foreground shadow-[0_30px_70px_-28px_hsl(var(--primary)/0.55)] sm:px-12 lg:px-20 lg:py-16">
73
+ <div className="mx-auto flex max-w-2xl flex-col items-center text-center">
74
+ <span
75
+ aria-hidden="true"
76
+ className="mb-5 flex h-12 w-12 items-center justify-center rounded-full bg-primary-foreground/10"
77
+ >
78
+ <IconMail size={24} />
79
+ </span>
80
+ <h2 className="text-4xl text-primary-foreground sm:text-5xl">{t('newsletterTitle')}</h2>
81
+ <p className="mt-3 max-w-lg text-primary-foreground/75">{t('newsletterSubtitle')}</p>
82
+
83
+ {done ? (
84
+ <p
85
+ role="status"
86
+ className="mt-8 inline-flex items-center gap-2 rounded-full bg-primary-foreground/10 px-5 py-3 text-sm font-semibold"
87
+ >
88
+ <IconCheck size={18} />
89
+ {t('newsletterSuccess')}
90
+ </p>
91
+ ) : (
92
+ <form
93
+ onSubmit={handleSubmit}
94
+ className="mt-8 flex w-full max-w-md flex-col gap-3 sm:flex-row"
95
+ >
96
+ <label className="sr-only" htmlFor="newsletter-email">
97
+ {t('newsletterPlaceholder')}
98
+ </label>
99
+ <input
100
+ id="newsletter-email"
101
+ type="email"
102
+ required
103
+ value={email}
104
+ onChange={(e) => setEmail(e.target.value)}
105
+ placeholder={t('newsletterPlaceholder')}
106
+ className="input h-12 flex-1 border-transparent bg-background text-foreground"
107
+ />
108
+ <button
109
+ type="submit"
110
+ disabled={loading}
111
+ className="btn h-12 shrink-0 bg-background px-7 text-foreground hover:bg-background/90 disabled:opacity-60"
112
+ >
113
+ {loading ? tn('submitting') : t('newsletterCta')}
114
+ </button>
115
+
116
+ {/*
117
+ Honeypot. Bots complete every text input; a human never sees
118
+ this one, so a non-empty value rejects the request
119
+ server-side. Positioned off-screen rather than
120
+ `display:none`, because some bots skip hidden inputs, and kept out
121
+ of the tab order and the accessibility tree.
122
+ */}
123
+ <input
124
+ type="text"
125
+ name="company_website"
126
+ tabIndex={-1}
127
+ autoComplete="off"
128
+ aria-hidden="true"
129
+ value={honeypot}
130
+ onChange={(e) => setHoneypot(e.target.value)}
131
+ style={{ position: 'absolute', left: '-9999px', width: 1, height: 1 }}
132
+ />
133
+ </form>
134
+ )}
135
+
136
+ {error ? (
137
+ <p role="alert" className="mt-3 text-sm text-primary-foreground">
138
+ {error}
139
+ </p>
140
+ ) : null}
141
+
142
+ {!done && (
143
+ <p className="mt-3 text-xs text-primary-foreground/60">{t('newsletterPrivacy')}</p>
144
+ )}
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </section>
149
+ );
150
+ }