create-brainerce-store 1.83.0 → 1.85.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 (43) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +3 -0
  4. package/messages/he.json +3 -0
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.env.local.ejs +10 -1
  7. package/templates/nextjs/base/AGENTS.md.ejs +229 -207
  8. package/templates/nextjs/base/AI-GUIDE.md +4 -0
  9. package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
  10. package/templates/nextjs/base/scripts/connect.mjs +115 -18
  11. package/templates/nextjs/base/scripts/env-file.mjs +93 -0
  12. package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
  14. package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
  15. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  16. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  17. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  18. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  19. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  20. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +847 -761
  21. package/templates/nextjs/base/src/core/lib/render-mode.ts +49 -0
  22. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  23. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  24. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  25. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  26. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  27. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  28. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  29. package/templates/nextjs/designs/atelier/globals.css +408 -402
  30. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  31. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  32. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  33. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  34. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  35. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  36. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  37. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  38. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  39. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  40. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  41. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +558 -553
  42. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  43. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
@@ -1,151 +1,178 @@
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; slug: string | null; 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, slug: cat.slug ?? null, 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, slug, items }] of Array.from(byCategory.entries()).slice(0, 4)) {
54
- const img = pickImage(items);
55
- if (!img?.url) continue;
56
- // Prefer the real category landing page (SEO-indexed, has its own
57
- // written description) — this is the only place in the storefront that
58
- // links to it, so without a real slug it'd be an orphaned page. Fall
59
- // back to the filtered grid for categories with no slug yet.
60
- tiles.push({
61
- key: id,
62
- label: name,
63
- imageUrl: img.url,
64
- imageAlt: img.alt || name,
65
- href: slug
66
- ? `/category/${encodeURIComponent(slug)}`
67
- : `/products?category=${encodeURIComponent(id)}`,
68
- count: items.length,
69
- });
70
- }
71
- } else {
72
- // Derive from name prefixes — never hardcodes products, only groups them.
73
- for (const group of PREFIX_GROUPS) {
74
- const items = products.filter((p) =>
75
- group.prefixes.some((prefix) => p.name?.toLowerCase().startsWith(prefix))
76
- );
77
- const img = pickImage(items);
78
- if (!img?.url) continue;
79
- tiles.push({
80
- key: group.labelKey,
81
- label: t(group.labelKey),
82
- imageUrl: img.url,
83
- imageAlt: img.alt || t(group.labelKey),
84
- href: `/products?search=${encodeURIComponent(group.prefixes[0])}`,
85
- count: items.length,
86
- });
87
- }
88
- }
89
-
90
- if (tiles.length === 0) return null;
91
-
92
- return (
93
- <section id="categories" className="section-pad wash-sand relative overflow-hidden">
94
- <div aria-hidden="true" className="glow-blob -start-24 top-1/3 h-72 w-72 opacity-70" />
95
- <div className="container-page relative">
96
- <div className="mb-8 max-w-xl lg:mb-10">
97
- <span className="eyebrow">{t('categoriesEyebrow')}</span>
98
- <h2 className="text-4xl sm:text-5xl">{t('categoriesTitle')}</h2>
99
- <p className="text-muted-foreground mt-3">{t('categoriesSubtitle')}</p>
100
- </div>
101
-
102
- <ul
103
- className={`grid grid-cols-2 gap-3 sm:gap-5 lg:gap-6 ${
104
- tiles.length === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-4'
105
- }`}
106
- >
107
- {tiles.map((tile, idx) => (
108
- <li
109
- key={tile.key}
110
- className={
111
- // Odd tile count: let the last tile span the full row on
112
- // mobile so the 2-col grid never shows an orphan hole.
113
- tiles.length % 2 === 1 && idx === tiles.length - 1
114
- ? 'col-span-2 lg:col-span-1'
115
- : undefined
116
- }
117
- >
118
- <Link href={tile.href} className="card card-hover group block overflow-hidden">
119
- <span className="bg-secondary relative block aspect-square overflow-hidden">
120
- <Image
121
- src={tile.imageUrl}
122
- alt={tile.imageAlt}
123
- fill
124
- sizes="(max-width: 640px) 50vw, 25vw"
125
- className="img-zoom object-cover"
126
- />
127
- </span>
128
- <span className="flex items-center justify-between gap-2 px-4 py-3.5">
129
- <span>
130
- <span className="text-foreground block text-[15px] font-semibold">
131
- {tile.label}
132
- </span>
133
- <span className="text-muted-foreground block text-xs">
134
- {tile.count} {tile.count === 1 ? tc('product') : tc('products')}
135
- </span>
136
- </span>
137
- <span
138
- aria-hidden="true"
139
- 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"
140
- >
141
- <IconArrowEnd size={16} className="rtl-flip" />
142
- </span>
143
- </span>
144
- </Link>
145
- </li>
146
- ))}
147
- </ul>
148
- </div>
149
- </section>
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 { IconArrowEnd, IconStar, IconSparkle } from '@/ui/shared/icons';
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
- <a href="#categories" className="btn-outline btn-lg bg-background/60 backdrop-blur-sm">
56
- {t('heroCtaSecondary')}
57
- </a>
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
- {/* Soft social proof */}
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
- {[1, 2, 3, 4, 5].map((n) => (
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 — real catalog photography */}
72
- {heroImage && (
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
- <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
- />
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
- {sideImage && (
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
- <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
- />
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
- {/* 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 && (
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
- <IconStar size={32} filled className="text-primary/20" />
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>