create-brainerce-store 1.84.0 → 1.86.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 +130 -92
- package/dist/index.js +39 -17
- package/messages/en.json +9 -2
- package/messages/he.json +9 -2
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +10 -1
- package/templates/nextjs/base/AGENTS.md.ejs +229 -207
- package/templates/nextjs/base/AI-GUIDE.md +4 -0
- package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
- package/templates/nextjs/base/scripts/connect.mjs +115 -18
- package/templates/nextjs/base/scripts/env-file.mjs +93 -0
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
- package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
- package/templates/nextjs/base/src/app/icon.svg +11 -0
- package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
- package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
- package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
- package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +130 -12
- package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +25 -14
- package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
- package/templates/nextjs/designs/atelier/globals.css +408 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
- package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +560 -553
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
- package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -52
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +428 -426
|
@@ -1,151 +1,178 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Category tiles derived from the live catalog.
|
|
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
|
-
|
|
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
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Category tiles derived from the live catalog.
|
|
5
|
+
*
|
|
6
|
+
* Real categories from the API are the only grouping. There is deliberately
|
|
7
|
+
* no name-prefix or keyword heuristic here: a guessed grouping matches one
|
|
8
|
+
* vertical and silently matches nothing on every other, so the section would
|
|
9
|
+
* vanish and look like a design choice.
|
|
10
|
+
*
|
|
11
|
+
* Three states, decided by the CATALOG, never by whether a photo exists:
|
|
12
|
+
* - 2+ categories → up to four category tiles
|
|
13
|
+
* - 1 category → that tile plus an "all products" tile, so the
|
|
14
|
+
* grid never shows a lone card
|
|
15
|
+
* - no categories yet → one wide "all products" tile
|
|
16
|
+
* A tile without a photo renders the typographic wash tile instead of being
|
|
17
|
+
* skipped, so a catalog seeded with names and prices only still gets the
|
|
18
|
+
* section. The section always carries `id="categories"`, which the hero's
|
|
19
|
+
* secondary CTA anchors to; it renders nothing only when the catalog is empty
|
|
20
|
+
* (the hero then points that CTA at /products instead).
|
|
21
|
+
*/
|
|
22
|
+
import type { Product } from 'brainerce';
|
|
23
|
+
import { Link } from '@/core/lib/navigation';
|
|
24
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
25
|
+
import { NoPhotoTile } from '@/ui/shared/no-photo-tile';
|
|
26
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
27
|
+
import { IconArrowEnd } from '@/ui/shared/icons';
|
|
28
|
+
|
|
29
|
+
interface Tile {
|
|
30
|
+
key: string;
|
|
31
|
+
label: string;
|
|
32
|
+
image: { url: string; alt: string } | null;
|
|
33
|
+
href: string;
|
|
34
|
+
/** Sub-line: a product count for a category, a "view all" for the catalog. */
|
|
35
|
+
meta: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MAX_TILES = 4;
|
|
39
|
+
|
|
40
|
+
export function CategoryTiles({ products }: { products: Product[] }) {
|
|
41
|
+
const t = useTranslations('home');
|
|
42
|
+
const tc = useTranslations('common');
|
|
43
|
+
const tp = useTranslations('products');
|
|
44
|
+
|
|
45
|
+
if (products.length === 0) return null;
|
|
46
|
+
|
|
47
|
+
const byCategory = new Map<string, { name: string; slug: string | null; items: Product[] }>();
|
|
48
|
+
for (const p of products) {
|
|
49
|
+
for (const cat of p.categories ?? []) {
|
|
50
|
+
const entry = byCategory.get(cat.id) ?? { name: cat.name, slug: cat.slug ?? null, items: [] };
|
|
51
|
+
entry.items.push(p);
|
|
52
|
+
byCategory.set(cat.id, entry);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Tile photo: prefer the LAST matching product's image — the featured grid
|
|
57
|
+
// above leads with the first ones, so tiles avoid repeating the same shots.
|
|
58
|
+
const pickImage = (items: Product[]) =>
|
|
59
|
+
[...items].reverse().find((p) => p.images?.[0]?.url)?.images?.[0];
|
|
60
|
+
|
|
61
|
+
const tiles: Tile[] = [];
|
|
62
|
+
for (const [id, { name, slug, items }] of Array.from(byCategory.entries()).slice(0, MAX_TILES)) {
|
|
63
|
+
const img = pickImage(items);
|
|
64
|
+
// Prefer the real category landing page (SEO-indexed, has its own
|
|
65
|
+
// written description) — this is the only place in the storefront that
|
|
66
|
+
// links to it, so without a real slug it'd be an orphaned page. Fall
|
|
67
|
+
// back to the filtered grid for categories with no slug yet.
|
|
68
|
+
tiles.push({
|
|
69
|
+
key: id,
|
|
70
|
+
label: name,
|
|
71
|
+
image: img?.url ? { url: img.url, alt: img.alt || name } : null,
|
|
72
|
+
href: slug
|
|
73
|
+
? `/category/${encodeURIComponent(slug)}`
|
|
74
|
+
: `/products?category=${encodeURIComponent(id)}`,
|
|
75
|
+
meta: `${items.length} ${items.length === 1 ? tc('product') : tc('products')}`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Floor: never a lone tile, never an empty section over a live catalog.
|
|
80
|
+
// No count here — the home page only loads the newest few products, so a
|
|
81
|
+
// number would understate the store.
|
|
82
|
+
const wideAllTile = tiles.length === 0;
|
|
83
|
+
if (tiles.length < 2) {
|
|
84
|
+
const label = tp('allProducts');
|
|
85
|
+
const img = pickImage(products);
|
|
86
|
+
tiles.push({
|
|
87
|
+
key: 'all-products',
|
|
88
|
+
label,
|
|
89
|
+
image: img?.url ? { url: img.url, alt: img.alt || label } : null,
|
|
90
|
+
href: '/products',
|
|
91
|
+
meta: tc('viewAll'),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<section id="categories" className="section-pad wash-sand relative overflow-hidden">
|
|
97
|
+
<div aria-hidden="true" className="glow-blob -start-24 top-1/3 h-72 w-72 opacity-70" />
|
|
98
|
+
<div className="container-page relative">
|
|
99
|
+
<div className="mb-8 max-w-xl lg:mb-10">
|
|
100
|
+
<span className="eyebrow">{t('categoriesEyebrow')}</span>
|
|
101
|
+
<h2 className="text-4xl sm:text-5xl">{t('categoriesTitle')}</h2>
|
|
102
|
+
<p className="text-muted-foreground mt-3">{t('categoriesSubtitle')}</p>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<ul
|
|
106
|
+
className={`grid grid-cols-2 gap-3 sm:gap-5 lg:gap-6 ${
|
|
107
|
+
tiles.length === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-4'
|
|
108
|
+
}`}
|
|
109
|
+
>
|
|
110
|
+
{tiles.map((tile, idx) => {
|
|
111
|
+
const wide = wideAllTile && idx === 0;
|
|
112
|
+
return (
|
|
113
|
+
<li
|
|
114
|
+
key={tile.key}
|
|
115
|
+
className={
|
|
116
|
+
wide
|
|
117
|
+
? 'col-span-2 lg:col-span-4'
|
|
118
|
+
: // Odd tile count: let the last tile span the full row on
|
|
119
|
+
// mobile so the 2-col grid never shows an orphan hole.
|
|
120
|
+
tiles.length % 2 === 1 && idx === tiles.length - 1
|
|
121
|
+
? 'col-span-2 lg:col-span-1'
|
|
122
|
+
: undefined
|
|
123
|
+
}
|
|
124
|
+
>
|
|
125
|
+
<Link
|
|
126
|
+
href={tile.href}
|
|
127
|
+
className={`card card-hover group overflow-hidden ${
|
|
128
|
+
wide ? 'grid sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]' : 'block'
|
|
129
|
+
}`}
|
|
130
|
+
>
|
|
131
|
+
<span
|
|
132
|
+
className={`bg-secondary relative block overflow-hidden ${
|
|
133
|
+
wide ? 'aspect-[16/9] sm:aspect-auto sm:min-h-[14rem]' : 'aspect-square'
|
|
134
|
+
}`}
|
|
135
|
+
>
|
|
136
|
+
{tile.image ? (
|
|
137
|
+
<Image
|
|
138
|
+
src={tile.image.url}
|
|
139
|
+
alt={tile.image.alt}
|
|
140
|
+
fill
|
|
141
|
+
sizes={wide ? '(max-width: 640px) 100vw, 40vw' : '(max-width: 640px) 50vw, 25vw'}
|
|
142
|
+
className="img-zoom object-cover"
|
|
143
|
+
/>
|
|
144
|
+
) : (
|
|
145
|
+
<NoPhotoTile name={tile.label} size={wide ? 'lg' : 'md'} />
|
|
146
|
+
)}
|
|
147
|
+
</span>
|
|
148
|
+
<span
|
|
149
|
+
className={`flex items-center justify-between gap-2 px-4 py-3.5 ${
|
|
150
|
+
wide ? 'sm:px-8 sm:py-8' : ''
|
|
151
|
+
}`}
|
|
152
|
+
>
|
|
153
|
+
<span className="min-w-0">
|
|
154
|
+
<span
|
|
155
|
+
className={`text-foreground block font-semibold ${
|
|
156
|
+
wide ? 'font-display text-2xl sm:text-3xl' : 'text-[15px]'
|
|
157
|
+
}`}
|
|
158
|
+
>
|
|
159
|
+
{tile.label}
|
|
160
|
+
</span>
|
|
161
|
+
<span className="text-muted-foreground block text-xs">{tile.meta}</span>
|
|
162
|
+
</span>
|
|
163
|
+
<span
|
|
164
|
+
aria-hidden="true"
|
|
165
|
+
className="bg-secondary text-foreground group-hover:bg-primary group-hover:text-primary-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors"
|
|
166
|
+
>
|
|
167
|
+
<IconArrowEnd size={16} className="rtl-flip" />
|
|
168
|
+
</span>
|
|
169
|
+
</span>
|
|
170
|
+
</Link>
|
|
171
|
+
</li>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
</ul>
|
|
175
|
+
</div>
|
|
176
|
+
</section>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -7,13 +7,26 @@ import { useTranslations } from '@/core/lib/translations';
|
|
|
7
7
|
import { PriceDisplay } from '@/ui/product/price-display';
|
|
8
8
|
import { pickDisplayPrice } from '@/core/lib/display-price';
|
|
9
9
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
10
|
-
import {
|
|
10
|
+
import { NoPhotoTile } from '@/ui/shared/no-photo-tile';
|
|
11
|
+
import { IconArrowEnd, IconShield, IconSparkle } from '@/ui/shared/icons';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Homepage hero — grid-breaking composition: oversized serif headline over a
|
|
14
15
|
* soft forest/sand radial wash, live-catalog photography as overlapping,
|
|
15
16
|
* slightly rotated floating cards with a glassy price chip. All imagery comes
|
|
16
17
|
* from useHomeData() in the parent; motion is reduced-motion guarded.
|
|
18
|
+
*
|
|
19
|
+
* Three collage states, decided by the CATALOG, not by the first photo:
|
|
20
|
+
* (a) products with a photo → floating photo cards (the designed look)
|
|
21
|
+
* (b) products, no photo yet → the same cards on a wash, name + price on
|
|
22
|
+
* the chip, still linking to the product. A
|
|
23
|
+
* store seeded with names and prices only
|
|
24
|
+
* is the normal first state of every new
|
|
25
|
+
* store, so it must look intentional.
|
|
26
|
+
* (c) no products at all → decorative composition with the
|
|
27
|
+
* "your first products appear here" hint.
|
|
28
|
+
* The old check keyed on `!heroImage`, which showed the "empty catalog"
|
|
29
|
+
* hint over a catalog that was merely photo-less.
|
|
17
30
|
*/
|
|
18
31
|
export function HeroSection({
|
|
19
32
|
storeName,
|
|
@@ -29,6 +42,7 @@ export function HeroSection({
|
|
|
29
42
|
const sideProduct = products[1];
|
|
30
43
|
const heroImage = heroProduct?.images?.[0]?.url;
|
|
31
44
|
const sideImage = sideProduct?.images?.[0]?.url;
|
|
45
|
+
const hasProducts = Boolean(heroProduct);
|
|
32
46
|
|
|
33
47
|
return (
|
|
34
48
|
<section className="wash-hero relative overflow-hidden">
|
|
@@ -52,24 +66,32 @@ export function HeroSection({
|
|
|
52
66
|
{tc('shopNow')}
|
|
53
67
|
<IconArrowEnd size={20} className="rtl-flip" />
|
|
54
68
|
</Link>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
{/* `#categories` is owned by <CategoryTiles>, which renders for any
|
|
70
|
+
non-empty catalog. With no products there is no section, so
|
|
71
|
+
the anchor would be dead and the CTA is dropped ("Shop now"
|
|
72
|
+
already leads to the listing). */}
|
|
73
|
+
{hasProducts && (
|
|
74
|
+
<a href="#categories" className="btn-outline btn-lg bg-background/60 backdrop-blur-sm">
|
|
75
|
+
{t('heroCtaSecondary')}
|
|
76
|
+
</a>
|
|
77
|
+
)}
|
|
58
78
|
</div>
|
|
59
79
|
|
|
60
|
-
{/*
|
|
80
|
+
{/* Factual trust line. Never a star row here: stars are a rating,
|
|
81
|
+
and a rating nobody gave is a fabricated review. Real ratings
|
|
82
|
+
render only from `avgRating` / `reviewCount` (product cards,
|
|
83
|
+
the rated band). */}
|
|
61
84
|
<p className="mt-9 inline-flex items-center gap-2 text-sm text-muted-foreground">
|
|
62
85
|
<span className="inline-flex text-primary" aria-hidden="true">
|
|
63
|
-
{
|
|
64
|
-
<IconStar key={n} size={16} filled />
|
|
65
|
-
))}
|
|
86
|
+
<IconShield size={18} />
|
|
66
87
|
</span>
|
|
67
88
|
{t('heroTrust')}
|
|
68
89
|
</p>
|
|
69
90
|
</div>
|
|
70
91
|
|
|
71
|
-
{/* Overlapping floating collage —
|
|
72
|
-
|
|
92
|
+
{/* Overlapping floating collage — states (a) and (b): real products,
|
|
93
|
+
photographed or not. */}
|
|
94
|
+
{hasProducts && (
|
|
73
95
|
<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
96
|
{/* Main card — tilted, floating */}
|
|
75
97
|
<Link
|
|
@@ -77,14 +99,18 @@ export function HeroSection({
|
|
|
77
99
|
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
100
|
style={{ '--float-rotate': '-2deg' } as React.CSSProperties}
|
|
79
101
|
>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
102
|
+
{heroImage ? (
|
|
103
|
+
<Image
|
|
104
|
+
src={heroImage}
|
|
105
|
+
alt={heroProduct.images?.[0]?.alt || heroProduct.name}
|
|
106
|
+
fill
|
|
107
|
+
priority
|
|
108
|
+
sizes="(max-width: 1024px) 80vw, 36vw"
|
|
109
|
+
className="img-zoom object-cover"
|
|
110
|
+
/>
|
|
111
|
+
) : (
|
|
112
|
+
<NoPhotoTile name={heroProduct.name} tone="forest" size="lg" />
|
|
113
|
+
)}
|
|
88
114
|
{/* Glassy shop-this chip */}
|
|
89
115
|
<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
116
|
<span className="min-w-0">
|
|
@@ -103,19 +129,23 @@ export function HeroSection({
|
|
|
103
129
|
</Link>
|
|
104
130
|
|
|
105
131
|
{/* Secondary card — overlaps the main one, counter-tilted */}
|
|
106
|
-
{
|
|
132
|
+
{sideProduct && (
|
|
107
133
|
<Link
|
|
108
134
|
href={`/products/${sideProduct.slug || sideProduct.id}`}
|
|
109
135
|
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
136
|
style={{ '--float-rotate': '3deg' } as React.CSSProperties}
|
|
111
137
|
>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
{sideImage ? (
|
|
139
|
+
<Image
|
|
140
|
+
src={sideImage}
|
|
141
|
+
alt={sideProduct.images?.[0]?.alt || sideProduct.name}
|
|
142
|
+
fill
|
|
143
|
+
sizes="(max-width: 1024px) 40vw, 16vw"
|
|
144
|
+
className="img-zoom object-cover"
|
|
145
|
+
/>
|
|
146
|
+
) : (
|
|
147
|
+
<NoPhotoTile name={sideProduct.name} showName />
|
|
148
|
+
)}
|
|
119
149
|
<span className="sr-only">{sideProduct.name}</span>
|
|
120
150
|
</Link>
|
|
121
151
|
)}
|
|
@@ -132,10 +162,10 @@ export function HeroSection({
|
|
|
132
162
|
</div>
|
|
133
163
|
)}
|
|
134
164
|
|
|
135
|
-
{/*
|
|
136
|
-
so a brand-new store still opens rich instead of
|
|
137
|
-
first two published products replace these
|
|
138
|
-
{!
|
|
165
|
+
{/* State (c): no products at all — the same floating composition as
|
|
166
|
+
designed washes, so a brand-new store still opens rich instead of
|
|
167
|
+
half-blank. The first two published products replace these. */}
|
|
168
|
+
{!hasProducts && (
|
|
139
169
|
<div
|
|
140
170
|
aria-hidden="true"
|
|
141
171
|
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"
|
|
@@ -153,7 +183,7 @@ export function HeroSection({
|
|
|
153
183
|
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
184
|
style={{ '--float-rotate': '3deg' } as React.CSSProperties}
|
|
155
185
|
>
|
|
156
|
-
<
|
|
186
|
+
<IconSparkle size={32} className="text-primary/25" />
|
|
157
187
|
</div>
|
|
158
188
|
</div>
|
|
159
189
|
)}
|
|
@@ -220,7 +220,7 @@ export function HeaderSearch({ className }: HeaderSearchProps) {
|
|
|
220
220
|
{index === 0 && productCount > 0 && (
|
|
221
221
|
<li
|
|
222
222
|
role="presentation"
|
|
223
|
-
className="text-muted-foreground px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide"
|
|
223
|
+
className="text-muted-foreground px-3 pb-1 pt-2 text-[11px] font-semibold ltr:uppercase ltr:tracking-wide"
|
|
224
224
|
>
|
|
225
225
|
{tp('pageTitle')}
|
|
226
226
|
</li>
|
|
@@ -228,7 +228,7 @@ export function HeaderSearch({ className }: HeaderSearchProps) {
|
|
|
228
228
|
{index === productCount && option.kind === 'category' && (
|
|
229
229
|
<li
|
|
230
230
|
role="presentation"
|
|
231
|
-
className="text-muted-foreground px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide"
|
|
231
|
+
className="text-muted-foreground px-3 pb-1 pt-2 text-[11px] font-semibold ltr:uppercase ltr:tracking-wide"
|
|
232
232
|
>
|
|
233
233
|
{t('categories')}
|
|
234
234
|
</li>
|