create-brainerce-store 1.72.0 → 1.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +37 -4
- package/messages/en.json +30 -1
- package/messages/he.json +30 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
|
@@ -1,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
|
-
|
|
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
|
-
</div>
|
|
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
|
-
title
|
|
87
|
-
items
|
|
88
|
-
className
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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="
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
|
21
|
+
export function StockBadge({ inventory, lowStockThreshold, className }: StockBadgeProps) {
|
|
15
22
|
const t = useTranslations('productDetail');
|
|
16
|
-
|
|
17
|
-
|
|
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] =
|
|
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
|
|
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] =
|
|
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
|
|
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": "
|
|
15
|
-
"benefit1Title": "
|
|
16
|
-
"benefit1Desc": "
|
|
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
|
|
19
|
-
"benefit3Title": "
|
|
20
|
-
"benefit3Desc": "
|
|
21
|
-
"benefit4Title": "
|
|
22
|
-
"benefit4Desc": "
|
|
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": "
|
|
41
|
-
"storyStat2Label": "
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
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": "
|
|
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": "
|
|
71
|
-
"guaranteeNote": "
|
|
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": "
|
|
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
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
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": "
|
|
71
|
-
"guaranteeNote": "
|
|
66
|
+
"shippingNote": "אפשרויות המשלוח מוצגות בקופה",
|
|
67
|
+
"guaranteeNote": "שאלה על ההזמנה? דברו איתנו",
|
|
72
68
|
"pickOne": "בחרו אחד",
|
|
73
69
|
"upTo": "עד {max}",
|
|
74
70
|
"pickExactly": "בחרו בדיוק {min}",
|