create-brainerce-store 1.72.0 → 1.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/index.js +37 -4
  2. package/messages/en.json +30 -1
  3. package/messages/he.json +30 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/AGENTS.md.ejs +63 -16
  6. package/templates/nextjs/base/AI-GUIDE.md +17 -4
  7. package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
  8. package/templates/nextjs/base/TRANSLATIONS.md +207 -207
  9. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  10. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  11. package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
  12. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  13. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  15. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
  17. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
  18. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  19. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  20. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  22. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
  23. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  24. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  25. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  26. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  27. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  28. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  29. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  30. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  31. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  32. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
  33. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  34. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
  35. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
  37. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  38. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  39. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  40. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  41. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  42. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  43. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  44. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  45. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  46. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  47. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  48. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  49. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  50. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  51. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  52. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
  53. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
  54. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  55. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
  56. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
  57. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  58. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  59. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  60. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  61. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  62. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  63. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  64. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  65. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  66. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  67. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  68. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  69. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  70. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  71. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  72. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  73. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  74. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
  75. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  76. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
  77. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  78. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
  79. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  80. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  81. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  82. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  83. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  84. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  85. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  86. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  87. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
  88. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
@@ -1,102 +1,108 @@
1
- 'use client';
2
-
3
- import { Image as ImageIcon } from 'lucide-react';
4
- import { Link } from '@/core/lib/navigation';
5
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
- import type { ProductRecommendation } from 'brainerce';
7
- import { PriceDisplay } from '@/ui/product/price-display';
8
- import { cn } from '@/core/lib/utils';
9
-
10
- interface RecommendationCardProps {
11
- item: ProductRecommendation;
12
- className?: string;
13
- }
14
-
15
- function RecommendationCard({ item, className }: RecommendationCardProps) {
16
- const firstImage = item.images?.[0];
17
- const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
18
- const slug = item.slug || item.id;
19
- const basePrice = parseFloat(item.basePrice);
20
- const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
21
- const isOnSale = salePrice != null && salePrice < basePrice;
22
-
23
- return (
24
- <Link
25
- href={`/products/${slug}`}
26
- className={cn(
27
- // Card-equivalent shell (bg-card + border + rounded-lg) as a link —
28
- // the whole recommendation tile is clickable.
29
- 'bg-card text-card-foreground group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
30
- className
31
- )}
32
- >
33
- <div className="bg-muted relative aspect-square overflow-hidden">
34
- {imageUrl ? (
35
- <Image
36
- src={imageUrl}
37
- alt={item.name}
38
- fill
39
- sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
40
- className="object-cover transition-transform duration-300 group-hover:scale-105"
41
- />
42
- ) : (
43
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
44
- <ImageIcon className="h-10 w-10" strokeWidth={1.5} aria-hidden="true" />
45
- </div>
46
- )}
47
- </div>
48
- <div className="space-y-1.5 p-3">
49
- <h3 className="text-foreground group-hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
50
- {item.name}
51
- </h3>
52
- <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
53
- </div>
54
- </Link>
55
- );
56
- }
57
-
58
- interface RecommendationSectionProps {
59
- title: string;
60
- items: ProductRecommendation[];
61
- className?: string;
62
- }
63
-
64
- export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
65
- if (items.length === 0) return null;
66
-
67
- return (
68
- <div className={cn('', className)}>
69
- <h2 className="text-foreground mb-4 text-xl font-semibold">{title}</h2>
70
- <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
71
- {items.map((item) => (
72
- <RecommendationCard key={item.id} item={item} />
73
- ))}
74
- </div>
75
- </div>
76
- );
77
- }
78
-
79
- interface CartRecommendationSectionProps {
80
- title: string;
81
- items: ProductRecommendation[];
82
- className?: string;
83
- }
84
-
85
- export function CartRecommendationSection({
86
- title,
87
- items,
88
- className,
89
- }: CartRecommendationSectionProps) {
90
- if (items.length === 0) return null;
91
-
92
- return (
93
- <div className={cn('', className)}>
94
- <h2 className="text-foreground mb-4 text-lg font-semibold">{title}</h2>
95
- <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
96
- {items.map((item) => (
97
- <RecommendationCard key={item.id} item={item} />
98
- ))}
99
- </div>
100
- </div>
101
- );
102
- }
1
+ 'use client';
2
+
3
+ import { Image as ImageIcon } from 'lucide-react';
4
+ import { Link } from '@/core/lib/navigation';
5
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
+ import type { ProductRecommendation } from 'brainerce';
7
+ import { PriceDisplay } from '@/ui/product/price-display';
8
+ import { cn } from '@/core/lib/utils';
9
+
10
+ interface RecommendationCardProps {
11
+ item: ProductRecommendation;
12
+ className?: string;
13
+ }
14
+
15
+ function RecommendationCard({ item, className }: RecommendationCardProps) {
16
+ const firstImage = item.images?.[0];
17
+ const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
18
+ const slug = item.slug || item.id;
19
+ // NOT region-converted, and it cannot be. `ProductRecommendation` is a
20
+ // trimmed shape that carries `basePrice` / `salePrice` only: the backend
21
+ // attaches no displayPrice/displayCurrency to it, so there is no converted
22
+ // amount to render and converting here would mean inventing an FX rate.
23
+ // These tiles stay in the store currency on a multi-region store. Do not
24
+ // "fix" it with a client-side conversion.
25
+ const basePrice = parseFloat(item.basePrice);
26
+ const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
27
+ const isOnSale = salePrice != null && salePrice < basePrice;
28
+
29
+ return (
30
+ <Link
31
+ href={`/products/${slug}`}
32
+ className={cn(
33
+ // Card-equivalent shell (bg-card + border + rounded-lg) as a link —
34
+ // the whole recommendation tile is clickable.
35
+ 'bg-card text-card-foreground group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
36
+ className
37
+ )}
38
+ >
39
+ <div className="bg-muted relative aspect-square overflow-hidden">
40
+ {imageUrl ? (
41
+ <Image
42
+ src={imageUrl}
43
+ alt={item.name}
44
+ fill
45
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
46
+ className="object-cover transition-transform duration-300 group-hover:scale-105"
47
+ />
48
+ ) : (
49
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
50
+ <ImageIcon className="h-10 w-10" strokeWidth={1.5} aria-hidden="true" />
51
+ </div>
52
+ )}
53
+ </div>
54
+ <div className="space-y-1.5 p-3">
55
+ <h3 className="text-foreground group-hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
56
+ {item.name}
57
+ </h3>
58
+ <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
59
+ </div>
60
+ </Link>
61
+ );
62
+ }
63
+
64
+ interface RecommendationSectionProps {
65
+ title: string;
66
+ items: ProductRecommendation[];
67
+ className?: string;
68
+ }
69
+
70
+ export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
71
+ if (items.length === 0) return null;
72
+
73
+ return (
74
+ <div className={cn('', className)}>
75
+ <h2 className="text-foreground mb-4 text-xl font-semibold">{title}</h2>
76
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
77
+ {items.map((item) => (
78
+ <RecommendationCard key={item.id} item={item} />
79
+ ))}
80
+ </div>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ interface CartRecommendationSectionProps {
86
+ title: string;
87
+ items: ProductRecommendation[];
88
+ className?: string;
89
+ }
90
+
91
+ export function CartRecommendationSection({
92
+ title,
93
+ items,
94
+ className,
95
+ }: CartRecommendationSectionProps) {
96
+ if (items.length === 0) return null;
97
+
98
+ return (
99
+ <div className={cn('', className)}>
100
+ <h2 className="text-foreground mb-4 text-lg font-semibold">{title}</h2>
101
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
102
+ {items.map((item) => (
103
+ <RecommendationCard key={item.id} item={item} />
104
+ ))}
105
+ </div>
106
+ </div>
107
+ );
108
+ }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
- import { Star, X } from 'lucide-react';
4
+ import { ImagePlus, Star, X } from 'lucide-react';
5
5
  import { getClient } from '@/core/lib/brainerce';
6
6
  import { checkAuthStatus } from '@/core/lib/auth';
7
7
  import { Link } from '@/core/lib/navigation';
@@ -371,7 +371,7 @@ function ReviewEditor({
371
371
  setUploads((prev) => prev.filter((item) => item.key !== upload.key))
372
372
  }
373
373
  aria-label={t('removePhoto')}
374
- className="rounded-bs-none rounded-be absolute end-0 top-0 bg-black/60 px-1 text-xs text-white"
374
+ className="absolute end-1 top-1 rounded-full bg-black/60 p-1 text-white"
375
375
  >
376
376
  <X className="h-3 w-3" aria-hidden="true" />
377
377
  </button>
@@ -380,16 +380,38 @@ function ReviewEditor({
380
380
  </ul>
381
381
  )}
382
382
 
383
- <input
384
- type="file"
385
- accept="image/jpeg,image/png,image/webp,image/gif"
386
- multiple
387
- disabled={uploading || roomLeft <= 0}
388
- onChange={handleFiles}
389
- className="block text-sm"
390
- />
383
+ {/* A <label> wrapping a visually hidden input, not a bare file control.
384
+ The browser's own "Choose Files" button cannot be styled and looks
385
+ nothing like the rest of the form. Two things this has to do that the
386
+ native control did for free: `disabled` on a hidden input greys
387
+ NOTHING, so the label paints its own disabled state, and the line
388
+ underneath says WHY it is disabled rather than leaving a dead button.
389
+ The input stays a real input (no ref.click()) so keyboard and
390
+ assistive tech reach it, and focus-within restores the focus ring. */}
391
+ <Button asChild variant="outline" size="sm" className="w-fit">
392
+ <label
393
+ className={`focus-within:ring-ring inline-flex cursor-pointer items-center gap-2 focus-within:ring-2 ${
394
+ uploading || roomLeft <= 0 ? 'pointer-events-none opacity-50' : ''
395
+ }`}
396
+ >
397
+ <ImagePlus className="h-4 w-4" aria-hidden="true" />
398
+ {uploading ? t('photoUploading') : t('choosePhotos')}
399
+ <input
400
+ type="file"
401
+ accept="image/jpeg,image/png,image/webp,image/gif"
402
+ multiple
403
+ disabled={uploading || roomLeft <= 0}
404
+ onChange={handleFiles}
405
+ className="sr-only"
406
+ />
407
+ </label>
408
+ </Button>
391
409
 
392
- {uploading && <p className="text-muted-foreground text-xs">{t('photoUploading')}</p>}
410
+ <p className="text-muted-foreground text-xs">
411
+ {roomLeft <= 0
412
+ ? t('photoLimitReached', { max: String(photos.maxPerReview) })
413
+ : t('photoFormats', { mb: String(Math.round(photos.maxBytes / (1024 * 1024))) })}
414
+ </p>
393
415
  {/* Say it BEFORE they submit. A shopper who uploads a photo, submits, and
394
416
  then cannot see it will conclude the site is broken. */}
395
417
  {photos.requiresApproval && (
@@ -4,17 +4,29 @@ import type { InventoryInfo } from 'brainerce';
4
4
  import { Badge } from '@/components/ui/badge';
5
5
  import { cn } from '@/core/lib/utils';
6
6
  import { useTranslations } from '@/core/lib/translations';
7
+ import { useStoreCapabilities } from '@/core/providers/store-provider';
8
+ import { resolveLowStockThreshold } from '@/core/lib/capabilities';
7
9
 
8
10
  interface StockBadgeProps {
9
11
  inventory: InventoryInfo | null | undefined;
12
+ /**
13
+ * Override the threshold. Leave it unset — the merchant's configured value
14
+ * comes from the store's capabilities, and a hardcoded number here shows the
15
+ * wrong badge on every store that chose a different one.
16
+ */
10
17
  lowStockThreshold?: number;
11
18
  className?: string;
12
19
  }
13
20
 
14
- export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
21
+ export function StockBadge({ inventory, lowStockThreshold, className }: StockBadgeProps) {
15
22
  const t = useTranslations('productDetail');
16
- const label = getStockLabel(inventory, lowStockThreshold, t);
17
- const color = getStockColor(inventory, lowStockThreshold);
23
+ // Merchant-configured, from the channel's capabilities. Falls back to the
24
+ // platform default while the fetch is in flight or if it failed, and resolves
25
+ // to 0 (nothing is ever low) when the merchant turned low-stock warnings off.
26
+ const { capabilities } = useStoreCapabilities();
27
+ const threshold = lowStockThreshold ?? resolveLowStockThreshold(capabilities);
28
+ const label = getStockLabel(inventory, threshold, t);
29
+ const color = getStockColor(inventory, threshold);
18
30
 
19
31
  return (
20
32
  <Badge
@@ -4,11 +4,13 @@ import { Assistant, Frank_Ruhl_Libre } from 'next/font/google';
4
4
  import { StoreProvider } from '@/core/providers/store-provider';
5
5
  import { AnnouncementBar } from '@/ui/layout/announcement-bar';
6
6
  import { SiteHeader } from '@/ui/layout/site-header';
7
+ import { DiscountBannerStrip } from '@/ui/home/discount-banner-strip';
7
8
  import { SiteFooter } from '@/ui/layout/site-footer';
8
9
  import { CartDrawer } from '@/ui/cart/cart-drawer';
9
10
  import { BrainerceBotWidget } from '@/components/brainerce-bot';
10
11
  import { TrackingBootstrap } from '@/components/tracking-bootstrap';
11
12
  import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
13
+ import { resolveRegion } from '@/core/lib/region.server';
12
14
  import { getDirection, getMessages, supportedLocales } from '@/i18n';
13
15
  import { getNonce } from '@/core/lib/nonce';
14
16
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
@@ -84,7 +86,8 @@ export default async function RootLayout({
84
86
  // seeded a particular content type yet. New stores ship with default rows
85
87
  // seeded by the backend (StoresService.seedDefaultContent).
86
88
  const client = await getServerClient(locale);
87
- const [announcements, siteHeader, siteFooter, storeInfo, messages] = await Promise.all([
89
+ const [announcements, siteHeader, siteFooter, storeInfo, messages, discountBanners, regionInfo] =
90
+ await Promise.all([
88
91
  client.content.announcement.list(locale).catch(() => []),
89
92
  client.content.header.get('main', locale).catch(() => null),
90
93
  client.content.footer.get('main', locale).catch(() => null),
@@ -96,6 +99,18 @@ export default async function RootLayout({
96
99
  // without this, every page (and every locale switch) shows raw
97
100
  // "namespace.key" strings until the client-only fetch resolves.
98
101
  getMessages(locale),
102
+ // Store-wide discount banners. Fetched here rather than on the home
103
+ // page so they render below the header on EVERY page. `.catch(() => [])`
104
+ // keeps a store with no active discounts (and an API hiccup) silent:
105
+ // the strip itself returns null on an empty array.
106
+ client.getDiscountBanners().catch(() => []),
107
+ // Which region prices this request: the shopper's cookie, else the edge
108
+ // geo header, else the store default. Resolved here so SSR and the client
109
+ // agree from frame 0 and every page shares one round trip (it is
110
+ // `cache()`-wrapped). Never throws: a store with no regions answers
111
+ // `{ regions: [], region: null }` and every consumer then sends no
112
+ // `regionId`, which is what this storefront did before regions existed.
113
+ resolveRegion(),
99
114
  ]);
100
115
 
101
116
  return (
@@ -136,7 +151,14 @@ export default async function RootLayout({
136
151
  />
137
152
  </head>
138
153
  <body className={`${sans.variable} ${serif.variable} font-sans`}>
139
- <StoreProvider locale={locale} initialStoreInfo={storeInfo} initialMessages={messages}>
154
+ <StoreProvider
155
+ locale={locale}
156
+ initialStoreInfo={storeInfo}
157
+ initialMessages={messages}
158
+ initialRegions={regionInfo.regions}
159
+ initialRegion={regionInfo.region}
160
+ initialCountry={regionInfo.country}
161
+ >
140
162
  {/* Mounted ahead of the page tree on purpose: React runs effects in
141
163
  tree order, so booting the tags here guarantees fbq/ttq exist by
142
164
  the time a page component fires its first e-commerce event. */}
@@ -144,6 +166,7 @@ export default async function RootLayout({
144
166
  <div className="min-h-screen flex flex-col">
145
167
  <AnnouncementBar announcements={announcements} />
146
168
  <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
169
+ <DiscountBannerStrip banners={discountBanners} />
147
170
  <main className="flex-1">{children}</main>
148
171
  <SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} locale={locale} />
149
172
  </div>
@@ -160,11 +183,13 @@ import { Assistant, Frank_Ruhl_Libre } from 'next/font/google';
160
183
  import { StoreProvider } from '@/core/providers/store-provider';
161
184
  import { AnnouncementBar } from '@/ui/layout/announcement-bar';
162
185
  import { SiteHeader } from '@/ui/layout/site-header';
186
+ import { DiscountBannerStrip } from '@/ui/home/discount-banner-strip';
163
187
  import { SiteFooter } from '@/ui/layout/site-footer';
164
188
  import { CartDrawer } from '@/ui/cart/cart-drawer';
165
189
  import { BrainerceBotWidget } from '@/components/brainerce-bot';
166
190
  import { TrackingBootstrap } from '@/components/tracking-bootstrap';
167
191
  import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
192
+ import { resolveRegion } from '@/core/lib/region.server';
168
193
  import { getNonce } from '@/core/lib/nonce';
169
194
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
170
195
  import './globals.css';
@@ -232,7 +257,8 @@ export default async function RootLayout({
232
257
  // seeded a particular content type yet. New stores ship with default rows
233
258
  // seeded by the backend (StoresService.seedDefaultContent).
234
259
  const client = await getServerClient();
235
- const [announcements, siteHeader, siteFooter, storeInfo] = await Promise.all([
260
+ const [announcements, siteHeader, siteFooter, storeInfo, discountBanners, regionInfo] =
261
+ await Promise.all([
236
262
  client.content.announcement.list().catch(() => []),
237
263
  client.content.header.get('main').catch(() => null),
238
264
  client.content.footer.get('main').catch(() => null),
@@ -240,6 +266,18 @@ export default async function RootLayout({
240
266
  // render with the real currency, feature flags, and i18n config at frame 0
241
267
  // — without this, Googlebot sees USD/defaults baked into the HTML.
242
268
  fetchStoreInfo(),
269
+ // Store-wide discount banners. Fetched here rather than on the home
270
+ // page so they render below the header on EVERY page. `.catch(() => [])`
271
+ // keeps a store with no active discounts (and an API hiccup) silent:
272
+ // the strip itself returns null on an empty array.
273
+ client.getDiscountBanners().catch(() => []),
274
+ // Which region prices this request: the shopper's cookie, else the edge
275
+ // geo header, else the store default. Resolved here so SSR and the client
276
+ // agree from frame 0 and every page shares one round trip (it is
277
+ // `cache()`-wrapped). Never throws: a store with no regions answers
278
+ // `{ regions: [], region: null }` and every consumer then sends no
279
+ // `regionId`, which is what this storefront did before regions existed.
280
+ resolveRegion(),
243
281
  ]);
244
282
 
245
283
  return (
@@ -280,7 +318,12 @@ export default async function RootLayout({
280
318
  />
281
319
  </head>
282
320
  <body className={`${sans.variable} ${serif.variable} font-sans`}>
283
- <StoreProvider initialStoreInfo={storeInfo}>
321
+ <StoreProvider
322
+ initialStoreInfo={storeInfo}
323
+ initialRegions={regionInfo.regions}
324
+ initialRegion={regionInfo.region}
325
+ initialCountry={regionInfo.country}
326
+ >
284
327
  {/* Mounted ahead of the page tree on purpose: React runs effects in
285
328
  tree order, so booting the tags here guarantees fbq/ttq exist by
286
329
  the time a page component fires its first e-commerce event. */}
@@ -288,6 +331,7 @@ export default async function RootLayout({
288
331
  <div className="min-h-screen flex flex-col">
289
332
  <AnnouncementBar announcements={announcements} />
290
333
  <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
334
+ <DiscountBannerStrip banners={discountBanners} />
291
335
  <main className="flex-1">{children}</main>
292
336
  <SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
293
337
  </div>
@@ -11,15 +11,15 @@
11
11
  "heroTitle": "Pieces that tell your story",
12
12
  "heroLead": "Thoughtfully designed, handcrafted with love — made to be with you through the small moments and the big ones.",
13
13
  "heroCtaSecondary": "Browse categories",
14
- "heroTrust": "Happy customers nationwide",
15
- "benefit1Title": "Fast home delivery",
16
- "benefit1Desc": "Express courier nationwide, gift-wrapped",
14
+ "heroTrust": "Secure checkout on every order",
15
+ "benefit1Title": "Delivery",
16
+ "benefit1Desc": "Shipping options shown at checkout",
17
17
  "benefit2Title": "Secure payment",
18
- "benefit2Desc": "Encrypted checkout, highest standard",
19
- "benefit3Title": "Quality materials",
20
- "benefit3Desc": "Carefully sourced, built to last",
21
- "benefit4Title": "Easy returns",
22
- "benefit4Desc": "30 days to exchange or return, no questions",
18
+ "benefit2Desc": "Encrypted checkout on every order",
19
+ "benefit3Title": "Chosen with care",
20
+ "benefit3Desc": "A small, considered collection",
21
+ "benefit4Title": "Returns",
22
+ "benefit4Desc": "See our returns policy for details",
23
23
  "featuredEyebrow": "Our picks",
24
24
  "featuredSubtitle": "The pieces our customers love most — hand-picked from the new collection.",
25
25
  "categoriesEyebrow": "Categories",
@@ -37,24 +37,17 @@
37
37
  "storyCta": "Discover the collection",
38
38
  "storyStat1Value": "Quality-checked",
39
39
  "storyStat1Label": "Every product is checked before it's listed",
40
- "storyStat2Value": "Fully guaranteed",
41
- "storyStat2Label": "Every order backed by our support",
42
- "testimonialsEyebrow": "Customer stories",
43
- "testimonialsTitle": "They already fell in love",
44
- "testimonial1Quote": "It arrived in gorgeous packaging and looks even better than the photos. I got compliments on day one.",
45
- "testimonial1Name": "Noa L.",
46
- "testimonial1Meta": "Tel Aviv",
47
- "testimonial2Quote": "I bought this as a gift for my mom and she has not put it down since. Amazing quality and truly personal service.",
48
- "testimonial2Name": "Shira B.",
49
- "testimonial2Meta": "Haifa",
50
- "testimonial3Quote": "It's exactly as described, the work is delicate and precise. You can feel every detail was made with love. Highly recommend.",
51
- "testimonial3Name": "Dana K.",
52
- "testimonial3Meta": "Jerusalem",
40
+ "storyStat2Value": "Here to help",
41
+ "storyStat2Label": "Reach us with any question about an order",
42
+ "ratedEyebrow": "Customer ratings",
43
+ "ratedTitle": "Best rated by shoppers",
44
+ "ratedCount": "{count} reviews",
45
+ "ratedStars": "Rated {rating} out of 5",
53
46
  "newsletterTitle": "Join our club",
54
47
  "newsletterSubtitle": "Sign up to hear first about new collections, sales and member perks.",
55
48
  "newsletterPlaceholder": "Your email address",
56
49
  "newsletterCta": "Subscribe",
57
- "newsletterSuccess": "You are in! We promise to send only beautiful things.",
50
+ "newsletterSuccess": "Almost there. Check your email to confirm, and look in your spam folder too.",
58
51
  "newsletterPrivacy": "No spam. Unsubscribe anytime.",
59
52
  "heroEmptyHint": "Your first published products will appear here"
60
53
  },
@@ -63,12 +56,15 @@
63
56
  "filtersLabel": "Filter",
64
57
  "emptyTitle": "No results found",
65
58
  "emptyBody": "Try clearing the filters or searching for something else.",
66
- "clearFilters": "Clear filters"
59
+ "clearFilters": "Clear filters",
60
+ "priceLabel": "Price",
61
+ "priceMin": "Min",
62
+ "priceMax": "Max"
67
63
  },
68
64
  "productDetail": {
69
65
  "quantityLabel": "Quantity",
70
- "shippingNote": "Fast home delivery · Easy 30-day returns",
71
- "guaranteeNote": "Full warranty on every piece",
66
+ "shippingNote": "Shipping options shown at checkout",
67
+ "guaranteeNote": "Questions about your order? Get in touch",
72
68
  "pickOne": "Pick one",
73
69
  "upTo": "Up to {max}",
74
70
  "pickExactly": "Pick exactly {min}",
@@ -11,15 +11,15 @@
11
11
  "heroTitle": "פריטים שמספרים את הסיפור שלך",
12
12
  "heroLead": "עיצובים עדינים, בעבודת יד ובאהבה — נוצרו ללוות אתכן ברגעים הקטנים והגדולים.",
13
13
  "heroCtaSecondary": "לצפייה בקטגוריות",
14
- "heroTrust": "לקוחות מרוצות ברחבי הארץ",
15
- "benefit1Title": "משלוח מהיר עד הבית",
16
- "benefit1Desc": "שליחות מהירה לכל הארץ באריזת מתנה",
14
+ "heroTrust": "קופה מאובטחת בכל הזמנה",
15
+ "benefit1Title": "משלוח",
16
+ "benefit1Desc": "אפשרויות המשלוח מוצגות בקופה",
17
17
  "benefit2Title": "תשלום מאובטח",
18
- "benefit2Desc": "סליקה מוצפנת בתקן המחמיר ביותר",
19
- "benefit3Title": "חומרים איכותיים",
20
- "benefit3Desc": "נבחרים בקפידה, עמידים לאורך זמן",
21
- "benefit4Title": "החזרה קלה",
22
- "benefit4Desc": "30 יום להחלפה או החזרה, בלי שאלות",
18
+ "benefit2Desc": "קופה מוצפנת בכל הזמנה",
19
+ "benefit3Title": "נבחר בקפידה",
20
+ "benefit3Desc": "קולקציה קטנה ומוקפדת",
21
+ "benefit4Title": "החזרות",
22
+ "benefit4Desc": "הפרטים במדיניות ההחזרות שלנו",
23
23
  "featuredEyebrow": "הנבחרים שלנו",
24
24
  "featuredSubtitle": "הפריטים שהלקוחות שלנו הכי אוהבות — נבחרו בקפידה מהקולקציה החדשה.",
25
25
  "categoriesEyebrow": "קטגוריות",
@@ -37,24 +37,17 @@
37
37
  "storyCta": "לגלות את הקולקציה",
38
38
  "storyStat1Value": "איכות נבדקת",
39
39
  "storyStat1Label": "כל מוצר נבדק לפני שהוא עולה לאתר",
40
- "storyStat2Value": "אחריות מלאה",
41
- "storyStat2Label": "כל הזמנה מגובה בתמיכה שלנו",
42
- "testimonialsEyebrow": "לקוחות מספרות",
43
- "testimonialsTitle": "הן כבר התאהבו",
44
- "testimonial1Quote": "הפריט הגיע באריזה מהממת ונראה אפילו יותר טוב מבתמונות. קיבלתי מחמאות כבר ביום הראשון.",
45
- "testimonial1Name": "נועה ל.",
46
- "testimonial1Meta": "תל אביב",
47
- "testimonial2Quote": "קניתי מתנה לאמא שלי והיא לא מפסיקה להשתמש בו מאז. איכות מדהימה ושירות סופר אישי.",
48
- "testimonial2Name": "שירה ב.",
49
- "testimonial2Meta": "חיפה",
50
- "testimonial3Quote": "בדיוק כמו שתואר, העבודה עדינה ומוקפדת. מרגישים שכל פרט נעשה באהבה. ממליצה בחום.",
51
- "testimonial3Name": "דנה כ.",
52
- "testimonial3Meta": "ירושלים",
40
+ "storyStat2Value": "כאן בשבילכם",
41
+ "storyStat2Label": "אפשר לפנות אלינו בכל שאלה על הזמנה",
42
+ "ratedEyebrow": "דירוגי לקוחות",
43
+ "ratedTitle": "הכי מדורגים בחנות",
44
+ "ratedCount": "{count} ביקורות",
45
+ "ratedStars": "דירוג {rating} מתוך 5",
53
46
  "newsletterTitle": "הצטרפו למועדון שלנו",
54
47
  "newsletterSubtitle": "הרשמו וקבלו עדכון ראשונות על קולקציות חדשות, מבצעים והטבות לחברות מועדון.",
55
48
  "newsletterPlaceholder": "כתובת האימייל שלך",
56
49
  "newsletterCta": "הרשמה",
57
- "newsletterSuccess": "נרשמת בהצלחה! מבטיחים לשלוח רק דברים יפים.",
50
+ "newsletterSuccess": "כמעט סיימנו. בדקו את האימייל ואשרו את ההרשמה, והציצו גם בתיקיית הספאם.",
58
51
  "newsletterPrivacy": "בלי ספאם. אפשר להסיר את ההרשמה בכל רגע.",
59
52
  "heroEmptyHint": "המוצרים הראשונים שתפרסמו לחנות יופיעו כאן"
60
53
  },
@@ -63,12 +56,15 @@
63
56
  "filtersLabel": "סינון",
64
57
  "emptyTitle": "לא מצאנו תוצאות",
65
58
  "emptyBody": "נסו לנקות את הסינון או לחפש משהו אחר.",
66
- "clearFilters": "ניקוי סינון"
59
+ "clearFilters": "ניקוי סינון",
60
+ "priceLabel": "מחיר",
61
+ "priceMin": "מינימום",
62
+ "priceMax": "מקסימום"
67
63
  },
68
64
  "productDetail": {
69
65
  "quantityLabel": "כמות",
70
- "shippingNote": "משלוח מהיר עד הבית · החזרה קלה עד 30 יום",
71
- "guaranteeNote": "אחריות מלאה על כל פריט",
66
+ "shippingNote": "אפשרויות המשלוח מוצגות בקופה",
67
+ "guaranteeNote": "שאלה על ההזמנה? דברו איתנו",
72
68
  "pickOne": "בחרו אחד",
73
69
  "upTo": "עד {max}",
74
70
  "pickExactly": "בחרו בדיוק {min}",