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.
- package/dist/index.js +37 -4
- package/messages/en.json +30 -1
- package/messages/he.json +30 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
- 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 {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
{
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
*
|
|
19
|
-
* featured grid → category tiles → studio story →
|
|
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,
|
|
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
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|