create-brainerce-store 1.51.0 → 1.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/index.js +9 -68
- package/messages/en.json +32 -0
- package/messages/he.json +32 -0
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +9 -0
- package/templates/nextjs/base/AI-GUIDE.md +52 -3
- package/templates/nextjs/base/CLAUDE.md +10 -0
- package/templates/nextjs/base/package.json.ejs +52 -39
- package/templates/nextjs/base/src/app/globals.css +36 -30
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
- package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
- package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
- package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
- package/templates/nextjs/base/tailwind.config.ts +69 -42
- package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
- package/templates/nextjs/designs/atelier/globals.css +396 -0
- package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
- package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
- package/templates/nextjs/themes/luxury/globals.css +405 -399
- package/templates/nextjs/themes/minimal/globals.css +36 -30
- package/templates/nextjs/themes/playful/globals.css +406 -400
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
- package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
- package/templates/nextjs/designs/passepartout/globals.css +0 -412
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
- package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Category tiles derived from the live catalog. Uses product categories when
|
|
5
|
+
* present; otherwise derives collections from Hebrew product-name prefixes
|
|
6
|
+
* (טבעת / שרשרת / עגילי / צמיד) so the section is never empty on stores that
|
|
7
|
+
* haven't seeded category data. Each tile shows a real catalog photo.
|
|
8
|
+
*/
|
|
9
|
+
import type { Product } from 'brainerce';
|
|
10
|
+
import { Link } from '@/core/lib/navigation';
|
|
11
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
12
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
13
|
+
import { IconArrowEnd } from '@/ui/shared/icons';
|
|
14
|
+
|
|
15
|
+
const PREFIX_GROUPS = [
|
|
16
|
+
{ prefixes: ['טבעת', 'טבעות', 'ring'], labelKey: 'catRings' },
|
|
17
|
+
{ prefixes: ['שרשרת', 'שרשראות', 'necklace'], labelKey: 'catNecklaces' },
|
|
18
|
+
{ prefixes: ['עגיל', 'earring'], labelKey: 'catEarrings' },
|
|
19
|
+
{ prefixes: ['צמיד', 'bracelet'], labelKey: 'catBracelets' },
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
interface Tile {
|
|
23
|
+
key: string;
|
|
24
|
+
label: string;
|
|
25
|
+
imageUrl: string;
|
|
26
|
+
imageAlt: string;
|
|
27
|
+
href: string;
|
|
28
|
+
count: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function CategoryTiles({ products }: { products: Product[] }) {
|
|
32
|
+
const t = useTranslations('home');
|
|
33
|
+
const tc = useTranslations('common');
|
|
34
|
+
|
|
35
|
+
const tiles: Tile[] = [];
|
|
36
|
+
|
|
37
|
+
// Prefer real category data when the catalog has it.
|
|
38
|
+
const byCategory = new Map<string, { name: string; items: Product[] }>();
|
|
39
|
+
for (const p of products) {
|
|
40
|
+
for (const cat of p.categories ?? []) {
|
|
41
|
+
const entry = byCategory.get(cat.id) ?? { name: cat.name, items: [] };
|
|
42
|
+
entry.items.push(p);
|
|
43
|
+
byCategory.set(cat.id, entry);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Tile photo: prefer the LAST matching product's image — the featured grid
|
|
48
|
+
// above leads with the first ones, so tiles avoid repeating the same shots.
|
|
49
|
+
const pickImage = (items: Product[]) =>
|
|
50
|
+
[...items].reverse().find((p) => p.images?.[0]?.url)?.images?.[0];
|
|
51
|
+
|
|
52
|
+
if (byCategory.size >= 2) {
|
|
53
|
+
for (const [id, { name, items }] of Array.from(byCategory.entries()).slice(0, 4)) {
|
|
54
|
+
const img = pickImage(items);
|
|
55
|
+
if (!img?.url) continue;
|
|
56
|
+
tiles.push({
|
|
57
|
+
key: id,
|
|
58
|
+
label: name,
|
|
59
|
+
imageUrl: img.url,
|
|
60
|
+
imageAlt: img.alt || name,
|
|
61
|
+
href: `/products?category=${encodeURIComponent(id)}`,
|
|
62
|
+
count: items.length,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
// Derive from name prefixes — never hardcodes products, only groups them.
|
|
67
|
+
for (const group of PREFIX_GROUPS) {
|
|
68
|
+
const items = products.filter((p) =>
|
|
69
|
+
group.prefixes.some((prefix) => p.name?.toLowerCase().startsWith(prefix))
|
|
70
|
+
);
|
|
71
|
+
const img = pickImage(items);
|
|
72
|
+
if (!img?.url) continue;
|
|
73
|
+
tiles.push({
|
|
74
|
+
key: group.labelKey,
|
|
75
|
+
label: t(group.labelKey),
|
|
76
|
+
imageUrl: img.url,
|
|
77
|
+
imageAlt: img.alt || t(group.labelKey),
|
|
78
|
+
href: `/products?search=${encodeURIComponent(group.prefixes[0])}`,
|
|
79
|
+
count: items.length,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (tiles.length === 0) return null;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<section id="categories" className="section-pad wash-sand relative overflow-hidden">
|
|
88
|
+
<div aria-hidden="true" className="glow-blob -start-24 top-1/3 h-72 w-72 opacity-70" />
|
|
89
|
+
<div className="container-page relative">
|
|
90
|
+
<div className="mb-8 max-w-xl lg:mb-10">
|
|
91
|
+
<span className="eyebrow">{t('categoriesEyebrow')}</span>
|
|
92
|
+
<h2 className="text-4xl sm:text-5xl">{t('categoriesTitle')}</h2>
|
|
93
|
+
<p className="mt-3 text-muted-foreground">{t('categoriesSubtitle')}</p>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<ul
|
|
97
|
+
className={`grid grid-cols-2 gap-3 sm:gap-5 lg:gap-6 ${
|
|
98
|
+
tiles.length === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-4'
|
|
99
|
+
}`}
|
|
100
|
+
>
|
|
101
|
+
{tiles.map((tile, idx) => (
|
|
102
|
+
<li
|
|
103
|
+
key={tile.key}
|
|
104
|
+
className={
|
|
105
|
+
// Odd tile count: let the last tile span the full row on
|
|
106
|
+
// mobile so the 2-col grid never shows an orphan hole.
|
|
107
|
+
tiles.length % 2 === 1 && idx === tiles.length - 1
|
|
108
|
+
? 'col-span-2 lg:col-span-1'
|
|
109
|
+
: undefined
|
|
110
|
+
}
|
|
111
|
+
>
|
|
112
|
+
<Link
|
|
113
|
+
href={tile.href}
|
|
114
|
+
className="group card card-hover block overflow-hidden"
|
|
115
|
+
>
|
|
116
|
+
<span className="relative block aspect-square overflow-hidden bg-secondary">
|
|
117
|
+
<Image
|
|
118
|
+
src={tile.imageUrl}
|
|
119
|
+
alt={tile.imageAlt}
|
|
120
|
+
fill
|
|
121
|
+
sizes="(max-width: 640px) 50vw, 25vw"
|
|
122
|
+
className="img-zoom object-cover"
|
|
123
|
+
/>
|
|
124
|
+
</span>
|
|
125
|
+
<span className="flex items-center justify-between gap-2 px-4 py-3.5">
|
|
126
|
+
<span>
|
|
127
|
+
<span className="block text-[15px] font-semibold text-foreground">
|
|
128
|
+
{tile.label}
|
|
129
|
+
</span>
|
|
130
|
+
<span className="block text-xs text-muted-foreground">
|
|
131
|
+
{tile.count} {tile.count === 1 ? tc('product') : tc('products')}
|
|
132
|
+
</span>
|
|
133
|
+
</span>
|
|
134
|
+
<span
|
|
135
|
+
aria-hidden="true"
|
|
136
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors group-hover:bg-primary group-hover:text-primary-foreground"
|
|
137
|
+
>
|
|
138
|
+
<IconArrowEnd size={16} className="rtl-flip" />
|
|
139
|
+
</span>
|
|
140
|
+
</span>
|
|
141
|
+
</Link>
|
|
142
|
+
</li>
|
|
143
|
+
))}
|
|
144
|
+
</ul>
|
|
145
|
+
</div>
|
|
146
|
+
</section>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { DiscountBanner } from 'brainerce';
|
|
4
|
+
import { IconTag } from '@/ui/shared/icons';
|
|
5
|
+
|
|
6
|
+
/** Store-wide discount banner strip shown at the very top of the homepage. */
|
|
7
|
+
export function DiscountBannerStrip({ banners }: { banners: DiscountBanner[] }) {
|
|
8
|
+
if (banners.length === 0) return null;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div role="region" aria-label="Promotions" className="border-b bg-secondary">
|
|
12
|
+
<ul className="container-page flex flex-wrap items-center justify-center gap-x-8 gap-y-1 py-2.5">
|
|
13
|
+
{banners.map((banner) => (
|
|
14
|
+
<li
|
|
15
|
+
key={banner.ruleId}
|
|
16
|
+
className="inline-flex items-center gap-2 text-[13px] font-semibold text-foreground"
|
|
17
|
+
>
|
|
18
|
+
<span aria-hidden="true" className="text-primary">
|
|
19
|
+
<IconTag size={16} />
|
|
20
|
+
</span>
|
|
21
|
+
{banner.text}
|
|
22
|
+
</li>
|
|
23
|
+
))}
|
|
24
|
+
</ul>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Editorial "our studio" band — lifestyle photography (Pexels hotlinks,
|
|
5
|
+
* HEAD-verified) + brand-story copy from translation keys. Purely template
|
|
6
|
+
* marketing content; no catalog data is hardcoded here.
|
|
7
|
+
*/
|
|
8
|
+
import { Link } from '@/core/lib/navigation';
|
|
9
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
10
|
+
import { IconArrowEnd } from '@/ui/shared/icons';
|
|
11
|
+
|
|
12
|
+
// Verified free-stock imagery (images.pexels.com allows hotlinking).
|
|
13
|
+
const IMG_MODEL =
|
|
14
|
+
'https://images.pexels.com/photos/1454171/pexels-photo-1454171.jpeg?auto=compress&cs=tinysrgb&w=1200';
|
|
15
|
+
const IMG_DETAIL =
|
|
16
|
+
'https://images.pexels.com/photos/1191531/pexels-photo-1191531.jpeg?auto=compress&cs=tinysrgb&w=800';
|
|
17
|
+
|
|
18
|
+
export function EditorialBand() {
|
|
19
|
+
const t = useTranslations('home');
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<section className="section-pad">
|
|
23
|
+
<div className="container-page grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
|
|
24
|
+
{/* Image pair */}
|
|
25
|
+
<div className="grid grid-cols-12 gap-4">
|
|
26
|
+
<figure className="col-span-8 -rotate-2 overflow-hidden rounded-3xl border-4 border-background shadow-[0_28px_60px_-24px_hsl(var(--primary)/0.35)]">
|
|
27
|
+
{/* eslint-disable-next-line @next/next/no-img-element -- editorial stock hotlink, host not in next remotePatterns */}
|
|
28
|
+
<img
|
|
29
|
+
src={IMG_MODEL}
|
|
30
|
+
alt={t('storyTitle')}
|
|
31
|
+
loading="lazy"
|
|
32
|
+
className="aspect-[4/5] h-full w-full object-cover"
|
|
33
|
+
/>
|
|
34
|
+
</figure>
|
|
35
|
+
<figure className="col-span-4 -mb-8 self-end rotate-3 overflow-hidden rounded-3xl border-4 border-background shadow-[0_24px_50px_-20px_hsl(var(--primary)/0.35)]">
|
|
36
|
+
{/* eslint-disable-next-line @next/next/no-img-element -- editorial stock hotlink, host not in next remotePatterns */}
|
|
37
|
+
<img
|
|
38
|
+
src={IMG_DETAIL}
|
|
39
|
+
alt={t('storyStat1Value')}
|
|
40
|
+
loading="lazy"
|
|
41
|
+
className="aspect-[3/4] h-full w-full object-cover"
|
|
42
|
+
/>
|
|
43
|
+
</figure>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{/* Copy */}
|
|
47
|
+
<div className="max-w-xl">
|
|
48
|
+
<span className="eyebrow">{t('storyEyebrow')}</span>
|
|
49
|
+
<h2 className="text-4xl sm:text-5xl">{t('storyTitle')}</h2>
|
|
50
|
+
<p className="mt-5 leading-8 text-muted-foreground">{t('storyP1')}</p>
|
|
51
|
+
<p className="mt-4 leading-8 text-muted-foreground">{t('storyP2')}</p>
|
|
52
|
+
|
|
53
|
+
{/* Craft highlights */}
|
|
54
|
+
<dl className="mt-8 grid grid-cols-2 gap-6 border-t pt-8">
|
|
55
|
+
<div>
|
|
56
|
+
<dt className="font-display text-2xl text-foreground">{t('storyStat1Value')}</dt>
|
|
57
|
+
<dd className="mt-1 text-sm text-muted-foreground">{t('storyStat1Label')}</dd>
|
|
58
|
+
</div>
|
|
59
|
+
<div>
|
|
60
|
+
<dt className="font-display text-2xl text-foreground">{t('storyStat2Value')}</dt>
|
|
61
|
+
<dd className="mt-1 text-sm text-muted-foreground">{t('storyStat2Label')}</dd>
|
|
62
|
+
</div>
|
|
63
|
+
</dl>
|
|
64
|
+
|
|
65
|
+
<Link href="/products" className="btn-outline btn-md mt-8">
|
|
66
|
+
{t('storyCta')}
|
|
67
|
+
<IconArrowEnd size={18} className="rtl-flip" />
|
|
68
|
+
</Link>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Product } from 'brainerce';
|
|
4
|
+
import { Link } from '@/core/lib/navigation';
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { ProductGrid } from '@/ui/product/product-grid';
|
|
7
|
+
import { IconArrowEnd } from '@/ui/shared/icons';
|
|
8
|
+
|
|
9
|
+
/** Homepage featured-products grid with a section header + view-all link. */
|
|
10
|
+
export function FeaturedProductsSection({ products }: { products: Product[] }) {
|
|
11
|
+
const t = useTranslations('home');
|
|
12
|
+
const tc = useTranslations('common');
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<section className="section-pad">
|
|
16
|
+
<div className="container-page">
|
|
17
|
+
<div className="mb-8 flex flex-wrap items-end justify-between gap-4 lg:mb-10">
|
|
18
|
+
<div className="max-w-xl">
|
|
19
|
+
<span className="eyebrow">{t('featuredEyebrow')}</span>
|
|
20
|
+
<h2 className="text-4xl sm:text-5xl">{t('featuredProducts')}</h2>
|
|
21
|
+
<p className="mt-3 text-muted-foreground">{t('featuredSubtitle')}</p>
|
|
22
|
+
</div>
|
|
23
|
+
<Link
|
|
24
|
+
href="/products"
|
|
25
|
+
className="group inline-flex items-center gap-1.5 text-sm font-semibold text-primary hover:underline"
|
|
26
|
+
>
|
|
27
|
+
{tc('viewAll')}
|
|
28
|
+
<IconArrowEnd size={16} className="rtl-flip" />
|
|
29
|
+
</Link>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<ProductGrid products={products.slice(0, 8)} />
|
|
33
|
+
</div>
|
|
34
|
+
</section>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
</div>
|
|
134
|
+
</section>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function HeroPrice({ product }: { product: Product }) {
|
|
139
|
+
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
140
|
+
return (
|
|
141
|
+
<PriceDisplay
|
|
142
|
+
price={originalPrice}
|
|
143
|
+
salePrice={isOnSale ? price : undefined}
|
|
144
|
+
size="sm"
|
|
145
|
+
className="mt-0.5"
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useStoreInfo } from '@/core/providers/store-provider';
|
|
4
|
+
import { useHomeData } from '@/core/hooks/use-home-data';
|
|
5
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
6
|
+
import { Reveal } from '@/ui/shared/reveal';
|
|
7
|
+
import { DiscountBannerStrip } from './discount-banner-strip';
|
|
8
|
+
import { HeroSection } from './hero-section';
|
|
9
|
+
import { BenefitsBand } from './benefits-band';
|
|
10
|
+
import { FeaturedProductsSection } from './featured-products-section';
|
|
11
|
+
import { CategoryTiles } from './category-tiles';
|
|
12
|
+
import { EditorialBand } from './editorial-band';
|
|
13
|
+
import { TestimonialsBand } from './testimonials-band';
|
|
14
|
+
import { NewsletterSection } from './newsletter-section';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 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.
|
|
20
|
+
* Sections fade up on scroll via <Reveal> (reduced-motion safe).
|
|
21
|
+
*/
|
|
22
|
+
export function HomeClient() {
|
|
23
|
+
const { storeInfo, loading: storeLoading } = useStoreInfo();
|
|
24
|
+
const { products, banners, loading } = useHomeData();
|
|
25
|
+
|
|
26
|
+
if (storeLoading || loading) {
|
|
27
|
+
return <LoadingSpinner size="lg" className="min-h-[70vh]" />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="overflow-x-clip">
|
|
32
|
+
<DiscountBannerStrip banners={banners} />
|
|
33
|
+
<HeroSection storeName={storeInfo?.name} products={products} />
|
|
34
|
+
<Reveal>
|
|
35
|
+
<BenefitsBand />
|
|
36
|
+
</Reveal>
|
|
37
|
+
<Reveal>
|
|
38
|
+
<FeaturedProductsSection products={products} />
|
|
39
|
+
</Reveal>
|
|
40
|
+
<Reveal>
|
|
41
|
+
<CategoryTiles products={products} />
|
|
42
|
+
</Reveal>
|
|
43
|
+
<Reveal>
|
|
44
|
+
<EditorialBand />
|
|
45
|
+
</Reveal>
|
|
46
|
+
<Reveal>
|
|
47
|
+
<TestimonialsBand />
|
|
48
|
+
</Reveal>
|
|
49
|
+
<Reveal>
|
|
50
|
+
<NewsletterSection />
|
|
51
|
+
</Reveal>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/** Testimonials band — three quote cards, copy from translation keys. */
|
|
4
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
+
import { IconStar, IconQuote } from '@/ui/shared/icons';
|
|
6
|
+
|
|
7
|
+
const TESTIMONIALS = [1, 2, 3] as const;
|
|
8
|
+
|
|
9
|
+
export function TestimonialsBand() {
|
|
10
|
+
const t = useTranslations('home');
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<section className="section-pad wash-sand">
|
|
14
|
+
<div className="container-page">
|
|
15
|
+
<div className="mb-8 max-w-xl lg:mb-10">
|
|
16
|
+
<span className="eyebrow">{t('testimonialsEyebrow')}</span>
|
|
17
|
+
<h2 className="text-4xl sm:text-5xl">{t('testimonialsTitle')}</h2>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<ul className="grid gap-4 sm:gap-6 lg:grid-cols-3">
|
|
21
|
+
{TESTIMONIALS.map((n) => (
|
|
22
|
+
<li key={n}>
|
|
23
|
+
<figure className={`card card-hover flex h-full flex-col gap-4 p-6 ${n === 2 ? 'lg:rotate-1' : 'lg:-rotate-1'} lg:hover:rotate-0`}>
|
|
24
|
+
<span className="text-primary/30" aria-hidden="true">
|
|
25
|
+
<IconQuote size={28} />
|
|
26
|
+
</span>
|
|
27
|
+
<span className="inline-flex text-primary" aria-label="5/5">
|
|
28
|
+
{[1, 2, 3, 4, 5].map((s) => (
|
|
29
|
+
<IconStar key={s} size={16} filled />
|
|
30
|
+
))}
|
|
31
|
+
</span>
|
|
32
|
+
<blockquote className="flex-1 leading-7 text-foreground">
|
|
33
|
+
“{t(`testimonial${n}Quote`)}”
|
|
34
|
+
</blockquote>
|
|
35
|
+
<figcaption className="border-t pt-4 text-sm">
|
|
36
|
+
<span className="font-semibold text-foreground">{t(`testimonial${n}Name`)}</span>
|
|
37
|
+
<span className="text-muted-foreground"> · {t(`testimonial${n}Meta`)}</span>
|
|
38
|
+
</figcaption>
|
|
39
|
+
</figure>
|
|
40
|
+
</li>
|
|
41
|
+
))}
|
|
42
|
+
</ul>
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
);
|
|
46
|
+
}
|